Compare commits
	
		
			34 Commits
		
	
	
		
			4cc9d239cf
			...
			docs
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4c4839e36a | |||
| 88f245a80d | |||
| 8878ec632e | |||
| e3339fff99 | |||
| 55457afcac | |||
| 2da9e17067 | |||
| 191a49977a | |||
| e1e23f5315 | |||
| 640b7d1ece | |||
| cda2726960 | |||
| 2efc052755 | |||
| 30d03147c7 | |||
| 37bc4b0206 | |||
| e670ebaac7 | |||
| c9b3127f55 | |||
| 2cd57d0e60 | |||
| 3a50753c8a | |||
| d66765c646 | |||
| 761970a4de | |||
| c93ee2733b | |||
| c51be0433a | |||
| 9d4c19823c | |||
| 4f6e833a20 | |||
| 2a27207b41 | |||
| 2673539af1 | |||
| bb68cb9234 | |||
| 5286fa9617 | |||
| d411919288 | |||
| cd1cc283cd | |||
| 55b36b6042 | |||
| c63f53b69a | |||
| 32c99515dd | |||
| 5e317ab304 | |||
| 20b6babc37 | 
							
								
								
									
										7
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								.env
									
									
									
									
									
								
							| @@ -1,7 +0,0 @@ | ||||
| # --- Conexión a la Base de Datos --- | ||||
| DB_CONNECTION_STRING="Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" | ||||
|  | ||||
| # --- Claves de APIs Externas --- | ||||
| FINNHUB_API_KEY="cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30" | ||||
| BCR_API_KEY="D1782A51-A5FD-EF11-9445-00155D09E201" | ||||
| BCR_API_SECRET="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" | ||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -178,6 +178,10 @@ DocProject/Help/*.hhk | ||||
| DocProject/Help/*.hhp | ||||
| DocProject/Help/Html2 | ||||
| DocProject/Help/html | ||||
| # DocFx | ||||
| [Dd]ocs/ | ||||
| docfx.build.json | ||||
| docfx.metadata.json | ||||
|  | ||||
| # Click-Once directory | ||||
| publish/ | ||||
| @@ -412,5 +416,4 @@ FodyWeavers.xsd | ||||
| .history/ | ||||
|  | ||||
| # Built Visual Studio Code Extensions | ||||
| *.vsix | ||||
|  | ||||
| *.vsix | ||||
							
								
								
									
										63
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| services: | ||||
|   # Servicio del Backend API | ||||
|   mercados-api: | ||||
|     build: | ||||
|       context: ./Mercados-Web # Asumiendo que clonaste el repo en esta carpeta | ||||
|       dockerfile: src/Mercados.Api/Dockerfile | ||||
|     container_name: mercados-api | ||||
|     restart: always | ||||
|     env_file: | ||||
|       - ./.env # Lee las variables desde un archivo .env en la misma carpeta | ||||
|     networks: | ||||
|       - mercados-net | ||||
|       - shared-net # Se conecta a la red compartida para hablar con la DB | ||||
|     # NO se exponen puertos al host. | ||||
|  | ||||
|   # Servicio del Worker | ||||
|   mercados-worker: | ||||
|     build: | ||||
|       context: ./Mercados-Web | ||||
|       dockerfile: src/Mercados.Worker/Dockerfile | ||||
|     container_name: mercados-worker | ||||
|     restart: always | ||||
|     env_file: | ||||
|       - ./.env | ||||
|     networks: | ||||
|       - shared-net # Solo necesita acceso a la DB. | ||||
|     # NO se exponen puertos al host. | ||||
|  | ||||
|   # Servicio del Frontend (servido por Nginx) | ||||
|   mercados-frontend: | ||||
|     build: | ||||
|       context: ./Mercados-Web/frontend | ||||
|       dockerfile: Dockerfile | ||||
|     container_name: mercados-frontend | ||||
|     restart: always | ||||
|     networks: | ||||
|       - mercados-net | ||||
|     # NO se exponen puertos al host. | ||||
|  | ||||
|   # --- NUEVO SERVICIO: Proxy Inverso Local --- | ||||
|   proxy: | ||||
|     image: nginx:1.25-alpine | ||||
|     container_name: mercados-proxy | ||||
|     restart: always | ||||
|     volumes: | ||||
|       # Mapeamos nuestro archivo de configuración al contenedor de Nginx | ||||
|       - ./proxy-local/nginx.conf:/etc/nginx/conf.d/default.conf | ||||
|     ports: | ||||
|       # ESTE ES EL ÚNICO PUNTO DE ENTRADA DESDE EL EXTERIOR | ||||
|       # Expone el puerto 80 del contenedor al puerto 8500 del host Debian. | ||||
|       - "8500:80" | ||||
|     networks: | ||||
|       - mercados-net | ||||
|     depends_on: | ||||
|       - mercados-api | ||||
|       - mercados-frontend | ||||
|  | ||||
| networks: | ||||
|   mercados-net: | ||||
|     driver: bridge | ||||
|  | ||||
|   shared-net: | ||||
|     external: true | ||||
							
								
								
									
										19
									
								
								frontend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								frontend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| # --- Etapa 1: Build --- | ||||
| FROM node:20-alpine AS build | ||||
| WORKDIR /app | ||||
| COPY package*.json ./ | ||||
| RUN npm install | ||||
| COPY . . | ||||
| RUN npm run build | ||||
|  | ||||
| # --- Etapa 2: Producción --- | ||||
| FROM nginx:1.25-alpine | ||||
|  | ||||
| # Copiamos los archivos estáticos generados | ||||
| COPY --from=build /app/dist /usr/share/nginx/html | ||||
|  | ||||
| # Copiamos nuestra configuración personalizada de Nginx para el frontend | ||||
| COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| EXPOSE 80 | ||||
| CMD ["nginx", "-g", "daemon off;"] | ||||
							
								
								
									
										23
									
								
								frontend/frontend.nginx.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/frontend.nginx.conf
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| server { | ||||
|     listen 80; | ||||
|     server_name localhost; | ||||
|  | ||||
|     # Directorio raíz donde están los archivos de la app | ||||
|     root /usr/share/nginx/html; | ||||
|     index index.html; | ||||
|  | ||||
|     # Configuración para servir los archivos estáticos y manejar el enrutamiento de la SPA | ||||
|     location / { | ||||
|         # Intenta servir el archivo solicitado directamente ($uri), | ||||
|         # luego como un directorio ($uri/), | ||||
|         # y si no encuentra nada, devuelve el index.html | ||||
|         # Esto es crucial para que el enrutamiento de React funcione. | ||||
|         try_files $uri $uri/ /index.html; | ||||
|     } | ||||
|  | ||||
|     # Opcional: optimizaciones para archivos estáticos | ||||
|     location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2)$ { | ||||
|         expires 1y; | ||||
|         add_header Cache-Control "public"; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										70
									
								
								frontend/public/bootstrap.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								frontend/public/bootstrap.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| // frontend/public/bootstrap.js | ||||
|  | ||||
| (function() { | ||||
|   // El dominio donde se alojan los widgets | ||||
|   const WIDGETS_HOST = 'https://widgets.eldia.com'; | ||||
|  | ||||
|   // Función para cargar dinámicamente un script | ||||
|   function loadScript(src) { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const script = document.createElement('script'); | ||||
|       script.type = 'module'; | ||||
|       script.src = src; | ||||
|       script.onload = resolve; | ||||
|       script.onerror = reject; | ||||
|       document.head.appendChild(script); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Función para cargar dinámicamente una hoja de estilos | ||||
|   function loadCSS(href) { | ||||
|     const link = document.createElement('link'); | ||||
|     link.rel = 'stylesheet'; | ||||
|     link.href = href; | ||||
|     document.head.appendChild(link); | ||||
|   } | ||||
|  | ||||
|   // Función principal | ||||
|   async function initWidgets() { | ||||
|     try { | ||||
|       // 1. Obtener el manifest.json para saber los nombres de archivo actuales | ||||
|       const response = await fetch(`${WIDGETS_HOST}/manifest.json`); | ||||
|       if (!response.ok) { | ||||
|         throw new Error('No se pudo cargar el manifest de los widgets.'); | ||||
|       } | ||||
|       const manifest = await response.json(); | ||||
|  | ||||
|       // 2. Encontrar el punto de entrada principal (nuestro main.tsx) | ||||
|       const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry); | ||||
|       if (!entryKey) { | ||||
|         throw new Error('No se encontró el punto de entrada en el manifest.'); | ||||
|       } | ||||
|        | ||||
|       const entry = manifest[entryKey]; | ||||
|       const jsUrl = `${WIDGETS_HOST}/${entry.file}`; | ||||
|        | ||||
|       // 3. Cargar el CSS si existe | ||||
|       if (entry.css && entry.css.length > 0) { | ||||
|         entry.css.forEach(cssFile => { | ||||
|           const cssUrl = `${WIDGETS_HOST}/${cssFile}`; | ||||
|           loadCSS(cssUrl); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       // 4. Cargar el JS principal y esperar a que esté listo | ||||
|       await loadScript(jsUrl); | ||||
|  | ||||
|       // 5. Una vez cargado, llamar a la función de renderizado | ||||
|       if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') { | ||||
|         window.MercadosWidgets.render(); | ||||
|       } | ||||
|  | ||||
|     } catch (error) { | ||||
|       console.error('Error al inicializar los widgets de mercados:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Iniciar todo el proceso | ||||
|   initWidgets(); | ||||
|  | ||||
| })(); | ||||
| @@ -1,11 +1,7 @@ | ||||
| import axios from 'axios'; | ||||
|  | ||||
| // Durante el desarrollo, nuestra API corre en un puerto específico (ej. 5045). | ||||
| // En producción, esto debería apuntar a la URL real del servidor donde se despliegue la API. | ||||
| const API_BASE_URL = 'http://192.168.10.78:5045/api'; | ||||
|  | ||||
| // Eliminamos la baseURL de aquí para evitar cualquier confusión. | ||||
| const apiClient = axios.create({ | ||||
|   baseURL: API_BASE_URL, | ||||
|   headers: { | ||||
|     'Content-Type': 'application/json', | ||||
|   }, | ||||
|   | ||||
| @@ -2,15 +2,15 @@ import { Box, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | ||||
| import { formatFullDateTime } from '../utils/formatters'; | ||||
|  | ||||
| interface AgroHistoricalChartWidgetProps { | ||||
|   categoria: string; | ||||
|   especificaciones: string; | ||||
| } | ||||
|  | ||||
| const formatXAxis = (tickItem: string) => { | ||||
|     const date = new Date(tickItem); | ||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||
| const formatTooltipLabel = (label: string) => { | ||||
|     return formatFullDateTime(label); | ||||
| }; | ||||
|  | ||||
| export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroHistoricalChartWidgetProps) => { | ||||
| @@ -29,13 +29,20 @@ export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroH | ||||
|         return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar esta categoría.</Alert>; | ||||
|     } | ||||
|  | ||||
|     const formatXAxis = (tickItem: string) => { | ||||
|         return new Date(tickItem).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <ResponsiveContainer width="100%" height={300}> | ||||
|             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> | ||||
|                 <CartesianGrid strokeDasharray="3 3" /> | ||||
|                 <XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} /> | ||||
|                 <YAxis domain={['dataMin - 10', 'dataMax + 10']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> | ||||
|                 <Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio Promedio']} /> | ||||
|                 <Tooltip  | ||||
|                     formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio Promedio']}  | ||||
|                     labelFormatter={formatTooltipLabel} | ||||
|                 /> | ||||
|                 <Legend /> | ||||
|                 <Line type="monotone" dataKey="promedio" name="Precio Promedio" stroke="#028fbe" strokeWidth={2} dot={false} /> | ||||
|             </LineChart> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { useState } from 'react'; | ||||
| import React, { useState, useRef } from 'react'; | ||||
| import { | ||||
|   Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, | ||||
|   TableHead, TableRow, Paper, Typography, Dialog, DialogTitle, | ||||
| @@ -8,12 +8,19 @@ import CloseIcon from '@mui/icons-material/Close'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
| import { formatFullDateTime, formatCurrency } from '../utils/formatters'; | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
| import { PiChartLineUpBold } from 'react-icons/pi'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||
| import { formatFullDateTime, formatCurrency } from '../utils/formatters'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
| import { HolidayAlert } from './common/HolidayAlert'; | ||||
|  | ||||
| /** | ||||
|  * Sub-componente para mostrar la variación porcentual con un icono y color apropiado. | ||||
|  */ | ||||
| const Variacion = ({ value }: { value: number }) => { | ||||
|   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; | ||||
|   const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; | ||||
| @@ -25,95 +32,131 @@ const Variacion = ({ value }: { value: number }) => { | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Widget autónomo para la tabla de acciones líderes locales (Panel Merval). | ||||
|  */ | ||||
| export const BolsaLocalWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|   // Este widget obtiene todos los datos del mercado local y luego los filtra. | ||||
|   const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|   const isHoliday = useIsHoliday('BA'); | ||||
|    | ||||
|   const [selectedTicker, setSelectedTicker] = useState<string | null>(null); | ||||
|   const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||
|  | ||||
|   const handleRowClick = (ticker: string) => setSelectedTicker(ticker); | ||||
|   const handleCloseDialog = () => setSelectedTicker(null); | ||||
|  | ||||
|   const handleOpenModal = (ticker: string, event: React.MouseEvent<HTMLButtonElement>) => { | ||||
|     triggerButtonRef.current = event.currentTarget; | ||||
|     setSelectedTicker(ticker); | ||||
|   }; | ||||
|    | ||||
|   const handleCloseDialog = () => { | ||||
|     setSelectedTicker(null); | ||||
|     setTimeout(() => { | ||||
|       triggerButtonRef.current?.focus(); | ||||
|     }, 0); | ||||
|   }; | ||||
|    | ||||
|   // Filtramos para obtener solo las acciones, excluyendo el índice. | ||||
|   const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || []; | ||||
|  | ||||
|   if (loading) { | ||||
|   const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|   if (isLoading) { | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|   } | ||||
|  | ||||
|   if (error) { | ||||
|     return <Alert severity="error">{error}</Alert>; | ||||
|   if (dataError) { | ||||
|     return <Alert severity="error">{dataError}</Alert>; | ||||
|   } | ||||
|  | ||||
|   if (!data || data.length === 0) { | ||||
|     return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>; | ||||
|    | ||||
|   // Si después de filtrar no queda ninguna acción, mostramos el mensaje apropiado. | ||||
|   if (panelPrincipal.length === 0) { | ||||
|       // Si sabemos que es feriado, la alerta de feriado es el mensaje más relevante. | ||||
|       if (isHoliday) { | ||||
|           return <HolidayAlert />; | ||||
|       } | ||||
|       return <Alert severity="info">No hay acciones líderes disponibles para mostrar.</Alert>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|  | ||||
|       {panelPrincipal.length > 0 && ( | ||||
|         <TableContainer component={Paper}> | ||||
|           <Box sx={{ p: 1, m: 0 }}> | ||||
|             <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||
|               Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)} | ||||
|             </Typography> | ||||
|           </Box> | ||||
|           <Table size="small" aria-label="panel principal merval"> | ||||
|             <TableHead> | ||||
|               <TableRow> | ||||
|                 <TableCell>Símbolo</TableCell> | ||||
|                 <TableCell align="right">Precio Actual</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell> | ||||
|                 <TableCell align="center">% Cambio</TableCell> | ||||
|                 <TableCell align="center">Historial</TableCell> | ||||
|               </TableRow> | ||||
|             </TableHead> | ||||
|             <TableBody> | ||||
|               {panelPrincipal.map((row) => ( | ||||
|                 <TableRow key={row.ticker} hover> | ||||
|                   <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell> | ||||
|                   <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||
|                   <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>${formatCurrency(row.apertura)}</TableCell> | ||||
|                   <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>${formatCurrency(row.cierreAnterior)}</TableCell> | ||||
|                   <TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell> | ||||
|                   <TableCell align="center"> | ||||
|                     <IconButton | ||||
|                       aria-label={`ver historial de ${row.ticker}`} | ||||
|                       size="small" | ||||
|                       onClick={() => handleRowClick(row.ticker)} | ||||
|                       sx={{ | ||||
|                         boxShadow: '0 1px 3px rgba(0,0,0,0.1)', | ||||
|                         transition: 'all 0.2s ease-in-out', | ||||
|                         '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } | ||||
|                       }} | ||||
|                     > | ||||
|                       <PiChartLineUpBold size="18" /> | ||||
|                     </IconButton> | ||||
|                   </TableCell> | ||||
|                 </TableRow> | ||||
|               ))} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         </TableContainer> | ||||
|     <Box> | ||||
|       {/* La alerta de feriado también se aplica a esta tabla. */} | ||||
|       {isHoliday && ( | ||||
|         <Box sx={{ mb: 2 }}> | ||||
|           <HolidayAlert /> | ||||
|         </Box> | ||||
|       )} | ||||
|  | ||||
|       <TableContainer component={Paper}> | ||||
|         <Box sx={{ p: 1, m: 0 }}> | ||||
|           <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||
|             Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)} | ||||
|           </Typography> | ||||
|         </Box> | ||||
|         <Table size="small" aria-label="panel principal merval"> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell>Símbolo</TableCell> | ||||
|               <TableCell align="right">Precio Actual</TableCell> | ||||
|               <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell> | ||||
|               <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell> | ||||
|               <TableCell align="center">% Cambio</TableCell> | ||||
|               <TableCell align="center">Historial</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {panelPrincipal.map((row) => ( | ||||
|               <TableRow key={row.ticker} hover> | ||||
|                 <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell> | ||||
|                 <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>${formatCurrency(row.apertura)}</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>${formatCurrency(row.cierreAnterior)}</TableCell> | ||||
|                 <TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   <IconButton | ||||
|                     aria-label={`ver historial de ${row.ticker}`} | ||||
|                     size="small" | ||||
|                     onClick={(event) => handleOpenModal(row.ticker, event)} | ||||
|                     sx={{ | ||||
|                       boxShadow: '0 1px 3px rgba(0,0,0,0.1)', | ||||
|                       transition: 'all 0.2s ease-in-out', | ||||
|                       '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } | ||||
|                     }} | ||||
|                   > | ||||
|                     <PiChartLineUpBold size="18" /> | ||||
|                   </IconButton> | ||||
|                 </TableCell> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|  | ||||
|       <Dialog | ||||
|         open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth | ||||
|         open={Boolean(selectedTicker)} | ||||
|         onClose={handleCloseDialog} | ||||
|         maxWidth="md" | ||||
|         fullWidth | ||||
|         sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} | ||||
|       > | ||||
|         <IconButton | ||||
|           aria-label="close" onClick={handleCloseDialog} | ||||
|           aria-label="close" | ||||
|           onClick={handleCloseDialog} | ||||
|           sx={{ | ||||
|             position: 'absolute', top: -15, right: -15, color: (theme) => theme.palette.grey[500], | ||||
|             backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, | ||||
|             position: 'absolute', top: -15, right: -15, | ||||
|             color: (theme) => theme.palette.grey[500], | ||||
|             backgroundColor: 'white', boxShadow: 3, | ||||
|             '&:hover': { backgroundColor: 'grey.100' }, | ||||
|           }} | ||||
|         > | ||||
|           <CloseIcon /> | ||||
|         </IconButton> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}>Historial de 30 días para: {selectedTicker}</DialogTitle> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}> | ||||
|           Historial de 30 días para: {selectedTicker} | ||||
|         </DialogTitle> | ||||
|         <DialogContent dividers> | ||||
|           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" dias={30} />} | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { useState } from 'react'; | ||||
| import React, { useState, useRef } from 'react'; | ||||
| import { | ||||
|   Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, | ||||
|   TableHead, TableRow, Paper, Typography, Dialog, DialogTitle, | ||||
| @@ -8,12 +8,19 @@ import CloseIcon from '@mui/icons-material/Close'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
| import { formatFullDateTime, formatCurrency } from '../utils/formatters'; | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
| import { PiChartLineUpBold } from 'react-icons/pi'; | ||||
|  | ||||
| // Importaciones de modelos, hooks y utilidades | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||
| import { formatFullDateTime, formatCurrency } from '../utils/formatters'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
| import { HolidayAlert } from './common/HolidayAlert'; | ||||
|  | ||||
| /** | ||||
|  * Sub-componente para mostrar la variación porcentual con un icono y color apropiado. | ||||
|  */ | ||||
| const Variacion = ({ value }: { value: number }) => { | ||||
|   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; | ||||
|   const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; | ||||
| @@ -25,83 +32,135 @@ const Variacion = ({ value }: { value: number }) => { | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Widget autónomo para la tabla de acciones de EEUU y ADRs Argentinos. | ||||
|  */ | ||||
| export const BolsaUsaWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); | ||||
|   // 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 isHoliday = useIsHoliday('US'); // <-- Usamos el código de mercado 'US' | ||||
|    | ||||
|   // Estado y referencia para manejar el modal del gráfico. | ||||
|   const [selectedTicker, setSelectedTicker] = useState<string | null>(null); | ||||
|   const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||
|  | ||||
|   const handleRowClick = (ticker: string) => setSelectedTicker(ticker); | ||||
|   const handleCloseDialog = () => setSelectedTicker(null); | ||||
|  | ||||
|   const handleOpenModal = (ticker: string, event: React.MouseEvent<HTMLButtonElement>) => { | ||||
|     triggerButtonRef.current = event.currentTarget; | ||||
|     setSelectedTicker(ticker); | ||||
|   }; | ||||
|    | ||||
|   const handleCloseDialog = () => { | ||||
|     setSelectedTicker(null); | ||||
|     // Devuelve el foco al botón que abrió el modal para mejorar la accesibilidad. | ||||
|     setTimeout(() => { | ||||
|       triggerButtonRef.current?.focus(); | ||||
|     }, 0); | ||||
|   }; | ||||
|    | ||||
|   // Filtramos para obtener solo las acciones, excluyendo el índice S&P 500. | ||||
|   const otherStocks = data?.filter(d => d.ticker !== '^GSPC') || []; | ||||
|  | ||||
|   if (loading) { | ||||
|   // Estado de carga unificado: el componente está "cargando" si los datos principales | ||||
|   // o la información del feriado todavía no han llegado. | ||||
|   const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|   if (isLoading) { | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|   } | ||||
|  | ||||
|   if (error) { | ||||
|     return <Alert severity="error">{error}</Alert>; | ||||
|   if (dataError) { | ||||
|     return <Alert severity="error">{dataError}</Alert>; | ||||
|   } | ||||
|  | ||||
|   if (!data || data.length === 0) { | ||||
|     return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>; | ||||
|    | ||||
|   // Si después de filtrar no queda ninguna acción, mostramos el mensaje apropiado. | ||||
|   if (otherStocks.length === 0) { | ||||
|       // Si sabemos que es feriado, la alerta de feriado es el mensaje más relevante. | ||||
|       if (isHoliday) { | ||||
|           return <HolidayAlert />; | ||||
|       } | ||||
|       return <Alert severity="info">No hay acciones de EEUU disponibles para mostrar.</Alert>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* Renderizamos la tabla solo si hay otras acciones */} | ||||
|       {otherStocks.length > 0 && ( | ||||
|         <TableContainer component={Paper}> | ||||
|           <Box sx={{ p: 1, m: 0 }}> | ||||
|             <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||
|               Última actualización de acciones: {formatFullDateTime(otherStocks[0].fechaRegistro)} | ||||
|             </Typography> | ||||
|           </Box> | ||||
|           <Table size="small" aria-label="panel principal eeuu"> | ||||
|             <TableHead> | ||||
|               <TableRow> | ||||
|                 <TableCell>Símbolo</TableCell> | ||||
|                 <TableCell align="right">Precio Actual</TableCell> | ||||
|                  | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell> | ||||
|  | ||||
|                 <TableCell align="center">% Cambio</TableCell> | ||||
|                 <TableCell align="center">Historial</TableCell> | ||||
|               </TableRow> | ||||
|             </TableHead> | ||||
|             <TableBody> | ||||
|               {otherStocks.map((row) => ( | ||||
|                 <TableRow key={row.ticker} hover> | ||||
|                   <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell> | ||||
|                   <TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell> | ||||
|                    | ||||
|                   <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>{formatCurrency(row.apertura, 'USD')}</TableCell> | ||||
|                   <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>{formatCurrency(row.cierreAnterior, 'USD')}</TableCell> | ||||
|                    | ||||
|                   <TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell> | ||||
|                   <TableCell align="center"> | ||||
|                     <IconButton | ||||
|                       aria-label={`ver historial de ${row.ticker}`} size="small" | ||||
|                       onClick={() => handleRowClick(row.ticker)} | ||||
|                       sx={{ boxShadow: '0 1px 3px rgba(0,0,0,0.1)', transition: 'all 0.2s ease-in-out', '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } }} | ||||
|                     ><PiChartLineUpBold size="18" /></IconButton> | ||||
|                   </TableCell> | ||||
|                 </TableRow> | ||||
|               ))} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         </TableContainer> | ||||
|     <Box> | ||||
|       {/* Si es feriado, mostramos la alerta informativa en la parte superior. */} | ||||
|       {isHoliday && ( | ||||
|         <Box sx={{ mb: 2 }}> | ||||
|           <HolidayAlert /> | ||||
|         </Box> | ||||
|       )} | ||||
|  | ||||
|       <Dialog open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}> | ||||
|         <IconButton aria-label="close" onClick={handleCloseDialog} sx={{ position: 'absolute', top: -15, right: -15, color: (theme) => theme.palette.grey[500], backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, }}> | ||||
|       <TableContainer component={Paper}> | ||||
|         <Box sx={{ p: 1, m: 0 }}> | ||||
|           <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||
|             Última actualización de acciones: {formatFullDateTime(otherStocks[0].fechaRegistro)} | ||||
|           </Typography> | ||||
|         </Box> | ||||
|         <Table size="small" aria-label="panel principal eeuu"> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell>Símbolo</TableCell> | ||||
|               <TableCell align="right">Precio Actual</TableCell> | ||||
|               <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell> | ||||
|               <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell> | ||||
|               <TableCell align="center">% Cambio</TableCell> | ||||
|               <TableCell align="center">Historial</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {otherStocks.map((row) => ( | ||||
|               <TableRow key={row.ticker} hover> | ||||
|                 <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell> | ||||
|                 <TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>{formatCurrency(row.apertura, 'USD')}</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>{formatCurrency(row.cierreAnterior, 'USD')}</TableCell> | ||||
|                 <TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   <IconButton | ||||
|                     aria-label={`ver historial de ${row.ticker}`} | ||||
|                     size="small" | ||||
|                     onClick={(event) => handleOpenModal(row.ticker, event)} | ||||
|                     sx={{ | ||||
|                       boxShadow: '0 1px 3px rgba(0,0,0,0.1)', | ||||
|                       transition: 'all 0.2s ease-in-out', | ||||
|                       '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } | ||||
|                     }} | ||||
|                   > | ||||
|                     <PiChartLineUpBold size="18" /> | ||||
|                   </IconButton> | ||||
|                 </TableCell> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|  | ||||
|       <Dialog | ||||
|         open={Boolean(selectedTicker)} | ||||
|         onClose={handleCloseDialog} | ||||
|         maxWidth="md" | ||||
|         fullWidth | ||||
|         sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} | ||||
|       > | ||||
|         <IconButton | ||||
|           aria-label="close" | ||||
|           onClick={handleCloseDialog} | ||||
|           sx={{ | ||||
|             position: 'absolute', top: -15, right: -15, | ||||
|             color: (theme) => theme.palette.grey[500], | ||||
|             backgroundColor: 'white', boxShadow: 3, | ||||
|             '&:hover': { backgroundColor: 'grey.100' }, | ||||
|           }} | ||||
|         > | ||||
|           <CloseIcon /> | ||||
|         </IconButton> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}>Historial de 30 días para: {selectedTicker}</DialogTitle> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}> | ||||
|           Historial de 30 días para: {selectedTicker} | ||||
|         </DialogTitle> | ||||
|         <DialogContent dividers> | ||||
|           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" dias={30} />} | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| @@ -2,11 +2,16 @@ import { Box, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { CotizacionGrano } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | ||||
| import { formatFullDateTime } from '../utils/formatters'; | ||||
|  | ||||
| interface GrainsHistoricalChartWidgetProps { | ||||
|   nombre: string; | ||||
| } | ||||
|  | ||||
| const formatTooltipLabel = (label: string) => { | ||||
|     return formatFullDateTime(label); | ||||
| }; | ||||
|  | ||||
| const formatXAxis = (tickItem: string) => { | ||||
|     const date = new Date(tickItem); | ||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||
| @@ -32,9 +37,13 @@ export const GrainsHistoricalChartWidget = ({ nombre }: GrainsHistoricalChartWid | ||||
|         <ResponsiveContainer width="100%" height={300}> | ||||
|             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> | ||||
|                 <CartesianGrid strokeDasharray="3 3" /> | ||||
|                 {/* Para los granos, usamos la fecha de operación en el eje X, que es más relevante */} | ||||
|                 <XAxis dataKey="fechaOperacion" tickFormatter={formatXAxis} /> | ||||
|                 <YAxis domain={['dataMin - 1000', 'dataMax + 1000']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> | ||||
|                 <Tooltip formatter={(value: number) => [`$${value.toFixed(0)}`, 'Precio']} /> | ||||
|                 <Tooltip  | ||||
|                     formatter={(value: number) => [`$${value.toFixed(0)}`, 'Precio']}  | ||||
|                     labelFormatter={formatTooltipLabel} | ||||
|                 /> | ||||
|                 <Legend /> | ||||
|                 <Line type="monotone" dataKey="precio" name="Precio" stroke="#028fbe" strokeWidth={2} dot={false} /> | ||||
|             </LineChart> | ||||
|   | ||||
| @@ -8,13 +8,17 @@ import CloseIcon from '@mui/icons-material/Close'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
| import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi"; | ||||
| import { GiSunflower, GiWheat, GiCorn, GiGrainBundle } from "react-icons/gi"; | ||||
| import { TbGrain } from "react-icons/tb"; | ||||
|  | ||||
| import type { CotizacionGrano } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { formatInteger, formatDateOnly } from '../utils/formatters'; | ||||
| import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget'; | ||||
| import { LuBean } from 'react-icons/lu'; | ||||
|  | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||
| import { HolidayAlert } from './common/HolidayAlert'; | ||||
|  | ||||
| const getGrainIcon = (nombre: string) => { | ||||
|   switch (nombre.toLowerCase()) { | ||||
| @@ -22,7 +26,8 @@ const getGrainIcon = (nombre: string) => { | ||||
|     case 'trigo': return <GiWheat size={28} color="#fbc02d" />; | ||||
|     case 'sorgo': return <TbGrain size={28} color="#fbc02d" />; | ||||
|     case 'maiz': return <GiCorn size={28} color="#fbc02d" />; | ||||
|     default: return <GiGrain size={28} color="#fbc02d" />; | ||||
|     case 'soja': return <LuBean size={28} color="#fbc02d" />; | ||||
|     default: return <GiGrainBundle size={28} color="#fbc02d" />; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @@ -38,7 +43,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli | ||||
|       sx={{ | ||||
|         position: 'relative', | ||||
|         p: 2, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', | ||||
|         flex: '1 1 180px', minWidth: '180px', maxWidth: '220px', height: '160px', | ||||
|         flex: '1 1 180px', minWidth: '180px', maxWidth: '220px', height: '180px', | ||||
|         borderTop: `4px solid ${isPositive ? '#2e7d32' : isNegative ? '#d32f2f' : '#bdbdbd'}` | ||||
|       }} | ||||
|     > | ||||
| @@ -66,7 +71,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli | ||||
|       <Box sx={{ display: 'flex', alignItems: 'center', mb: 1, pr: 5 }}> | ||||
|         {getGrainIcon(grano.nombre)} | ||||
|         <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', ml: 1 }}> | ||||
|             {grano.nombre} | ||||
|           {grano.nombre} | ||||
|         </Typography> | ||||
|       </Box> | ||||
|  | ||||
| @@ -94,6 +99,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli | ||||
|  | ||||
| export const GranosCardWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos'); | ||||
|   const isHoliday = useIsHoliday('BA'); | ||||
|   const [selectedGrano, setSelectedGrano] = useState<string | null>(null); | ||||
|   const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||
|  | ||||
| @@ -105,11 +111,12 @@ export const GranosCardWidget = () => { | ||||
|   const handleCloseDialog = () => { | ||||
|     setSelectedGrano(null); | ||||
|     setTimeout(() => { | ||||
|         triggerButtonRef.current?.focus(); | ||||
|       triggerButtonRef.current?.focus(); | ||||
|     }, 0); | ||||
|   }; | ||||
|  | ||||
|   if (loading) { | ||||
|     // El spinner de carga sigue siendo prioritario | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|   } | ||||
|  | ||||
| @@ -123,41 +130,47 @@ export const GranosCardWidget = () => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|     <Box | ||||
|       sx={{ | ||||
|         display: 'flex', | ||||
|         flexWrap: 'wrap', | ||||
|         // Usamos el objeto para definir gaps responsivos | ||||
|         gap: { | ||||
|           xs: 4, // 16px de gap en pantallas extra pequeñas (afecta el espaciado vertical) | ||||
|           sm: 4, // 16px en pantallas pequeñas | ||||
|           md: 3, // 24px en pantallas medianas (afecta el espaciado horizontal) | ||||
|         }, | ||||
|         justifyContent: 'center' | ||||
|       }} | ||||
|     > | ||||
|       {data.map((grano) => ( | ||||
|       {/* Si es feriado (y la comprobación ha terminado), mostramos la alerta encima */} | ||||
|       {isHoliday === true && ( | ||||
|         <Box sx={{ mb: 2 }}> {/* Añadimos un margen inferior a la alerta */} | ||||
|           <HolidayAlert /> | ||||
|         </Box> | ||||
|       )} | ||||
|       <Box | ||||
|         sx={{ | ||||
|           display: 'flex', | ||||
|           flexWrap: 'wrap', | ||||
|           // Usamos el objeto para definir gaps responsivos | ||||
|           gap: { | ||||
|             xs: 4, // 16px de gap en pantallas extra pequeñas (afecta el espaciado vertical) | ||||
|             sm: 4, // 16px en pantallas pequeñas | ||||
|             md: 3, // 24px en pantallas medianas (afecta el espaciado horizontal) | ||||
|           }, | ||||
|           justifyContent: 'center' | ||||
|         }} | ||||
|       > | ||||
|         {data.map((grano) => ( | ||||
|           <GranoCard key={grano.nombre} grano={grano} onChartClick={(event) => handleChartClick(grano.nombre, event)} /> | ||||
|         ))} | ||||
|       </Box> | ||||
|       <Dialog open={Boolean(selectedGrano)} onClose={handleCloseDialog} maxWidth="md" fullWidth sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}> | ||||
|         <IconButton | ||||
|                     aria-label="close" | ||||
|                     onClick={handleCloseDialog} | ||||
|                     sx={{ | ||||
|                         position: 'absolute', | ||||
|                         top: -15, // Mueve el botón hacia arriba, fuera del Dialog | ||||
|                         right: -15, // Mueve el botón hacia la derecha, fuera del Dialog | ||||
|                         color: (theme) => theme.palette.grey[500], | ||||
|                         backgroundColor: 'white', | ||||
|                         boxShadow: 3, // Añade una sombra para que destaque | ||||
|                         '&:hover': { | ||||
|                             backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse | ||||
|                         }, | ||||
|                     }} | ||||
|                 > | ||||
|                     <CloseIcon /> | ||||
|                 </IconButton> | ||||
|           aria-label="close" | ||||
|           onClick={handleCloseDialog} | ||||
|           sx={{ | ||||
|             position: 'absolute', | ||||
|             top: -15, // Mueve el botón hacia arriba, fuera del Dialog | ||||
|             right: -15, // Mueve el botón hacia la derecha, fuera del Dialog | ||||
|             color: (theme) => theme.palette.grey[500], | ||||
|             backgroundColor: 'white', | ||||
|             boxShadow: 3, // Añade una sombra para que destaque | ||||
|             '&:hover': { | ||||
|               backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse | ||||
|             }, | ||||
|           }} | ||||
|         > | ||||
|           <CloseIcon /> | ||||
|         </IconButton> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}>Mensual de {selectedGrano}</DialogTitle> | ||||
|         <DialogContent dividers> | ||||
|           {selectedGrano && <GrainsHistoricalChartWidget nombre={selectedGrano} />} | ||||
|   | ||||
| @@ -2,19 +2,20 @@ import { | ||||
|   Box, CircularProgress, Alert, Table, TableBody, TableCell, | ||||
|   TableContainer, TableHead, TableRow, Paper, Typography, Tooltip | ||||
| } from '@mui/material'; | ||||
| import type { CotizacionGrano } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
|  | ||||
| const formatNumber = (num: number) => { | ||||
|   return new Intl.NumberFormat('es-AR', { | ||||
|     minimumFractionDigits: 0, | ||||
|     maximumFractionDigits: 2, | ||||
|   }).format(num); | ||||
| }; | ||||
| // Importaciones de nuestro proyecto | ||||
| import type { CotizacionGrano } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||
| import { formatInteger, formatDateOnly, formatFullDateTime } from '../utils/formatters'; | ||||
| import { HolidayAlert } from './common/HolidayAlert'; | ||||
|  | ||||
| /** | ||||
|  * Sub-componente para mostrar la variación con icono y color. | ||||
|  */ | ||||
| const Variacion = ({ value }: { value: number }) => { | ||||
|   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; | ||||
|   const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; | ||||
| @@ -23,58 +24,79 @@ const Variacion = ({ value }: { value: number }) => { | ||||
|     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||
|       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> | ||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}> | ||||
|         {formatNumber(value)} | ||||
|         {formatInteger(value)} | ||||
|       </Typography> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Widget autónomo para la tabla detallada del mercado de granos. | ||||
|  */ | ||||
| export const GranosWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos'); | ||||
|   // 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 isHoliday = useIsHoliday('BA'); | ||||
|  | ||||
|   if (loading) { | ||||
|   // Estado de carga unificado. | ||||
|   const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|   if (isLoading) { | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|   } | ||||
|  | ||||
|   if (error) { | ||||
|     return <Alert severity="error">{error}</Alert>; | ||||
|   if (dataError) { | ||||
|     return <Alert severity="error">{dataError}</Alert>; | ||||
|   } | ||||
|  | ||||
|   // Si no hay ningún dato que mostrar. | ||||
|   if (!data || data.length === 0) { | ||||
|     if (isHoliday) { | ||||
|       return <HolidayAlert />; | ||||
|     } | ||||
|     return <Alert severity="info">No hay datos de granos disponibles en este momento.</Alert>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <TableContainer component={Paper}> | ||||
|       <Table size="small" aria-label="tabla granos"> | ||||
|         <TableHead> | ||||
|           <TableRow> | ||||
|             <TableCell>Grano</TableCell> | ||||
|             <TableCell align="right">Precio ($/Tn)</TableCell> | ||||
|             <TableCell align="center">Variación</TableCell> | ||||
|             <TableCell align="right">Fecha Operación</TableCell> | ||||
|           </TableRow> | ||||
|         </TableHead> | ||||
|         <TableBody> | ||||
|           {data.map((row) => ( | ||||
|             <TableRow key={row.nombre} hover> | ||||
|               <TableCell component="th" scope="row"> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.nombre}</Typography> | ||||
|               </TableCell> | ||||
|               <TableCell align="right">${formatNumber(row.precio)}</TableCell> | ||||
|               <TableCell align="center"> | ||||
|                 <Variacion value={row.variacionPrecio} /> | ||||
|               </TableCell> | ||||
|               <TableCell align="right">{new Date(row.fechaOperacion).toLocaleDateString('es-AR')}</TableCell> | ||||
|     <Box> | ||||
|       {/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */} | ||||
|       {isHoliday && ( | ||||
|         <Box sx={{ mb: 2 }}> | ||||
|           <HolidayAlert /> | ||||
|         </Box> | ||||
|       )} | ||||
|  | ||||
|       <TableContainer component={Paper}> | ||||
|         <Table size="small" aria-label="tabla granos"> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell>Grano</TableCell> | ||||
|               <TableCell align="right">Precio ($/Tn)</TableCell> | ||||
|               <TableCell align="center">Variación</TableCell> | ||||
|               <TableCell align="right">Fecha Operación</TableCell> | ||||
|             </TableRow> | ||||
|           ))} | ||||
|         </TableBody> | ||||
|       </Table> | ||||
|       <Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}> | ||||
|         <Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}> | ||||
|           Fuente: Bolsa de Comercio de Rosario | ||||
|         </Typography> | ||||
|       </Tooltip> | ||||
|     </TableContainer> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {data.map((row) => ( | ||||
|               <TableRow key={row.nombre} hover> | ||||
|                 <TableCell component="th" scope="row"> | ||||
|                   <Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.nombre}</Typography> | ||||
|                 </TableCell> | ||||
|                 <TableCell align="right">${formatInteger(row.precio)}</TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   <Variacion value={row.variacionPrecio} /> | ||||
|                 </TableCell> | ||||
|                 <TableCell align="right">{formatDateOnly(row.fechaOperacion)}</TableCell> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|         <Tooltip title={`Última actualización: ${formatFullDateTime(data[0].fechaRegistro)}`}> | ||||
|           <Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}> | ||||
|             Fuente: Bolsa de Comercio de Rosario | ||||
|           </Typography> | ||||
|         </Tooltip> | ||||
|       </TableContainer> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| @@ -2,6 +2,7 @@ import { Box, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | ||||
| import { formatFullDateTime, formatCurrency2Decimal, formatCurrency } from '../utils/formatters'; | ||||
|  | ||||
| interface HistoricalChartWidgetProps { | ||||
|     ticker: string; | ||||
| @@ -9,10 +10,13 @@ interface HistoricalChartWidgetProps { | ||||
|     dias: number; | ||||
| } | ||||
|  | ||||
| // Formateador para el eje X (muestra DD/MM) | ||||
| // Formateador para el eje X que solo muestra día/mes | ||||
| const formatXAxis = (tickItem: string) => { | ||||
|     const date = new Date(tickItem); | ||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||
|     return new Date(tickItem).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||
| }; | ||||
|  | ||||
| const formatTooltipLabel = (label: string) => { | ||||
|     return formatFullDateTime(label); | ||||
| }; | ||||
|  | ||||
| export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => { | ||||
| @@ -24,22 +28,49 @@ export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChart | ||||
|     } | ||||
|  | ||||
|     if (error) { | ||||
|         return <Alert severity="error" sx={{height: 300}}>{error}</Alert>; | ||||
|         return <Alert severity="error" sx={{ height: 300 }}>{error}</Alert>; | ||||
|     } | ||||
|  | ||||
|     if (!data || data.length < 2) { | ||||
|         return <Alert severity="info" sx={{height: 300}}>No hay suficientes datos históricos para graficar.</Alert>; | ||||
|         return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar.</Alert>; | ||||
|     } | ||||
|  | ||||
|     // 1. Calcular el dominio del eje Y con un margen | ||||
|     const prices = data.map(p => p.precioActual); | ||||
|     const dataMin = Math.min(...prices); | ||||
|     const dataMax = Math.max(...prices); | ||||
|     const padding = (dataMax - dataMin) * 0.5; // 5% de padding | ||||
|     const domainMin = Math.floor(dataMin - padding); | ||||
|     const domainMax = Math.ceil(dataMax + padding); | ||||
|  | ||||
|     // 2. Formateador de ticks para el eje Y más robusto | ||||
|     const yAxisTickFormatter = (tick: number) => { | ||||
|         // Usamos el formateador de moneda | ||||
|         return `$${formatCurrency(tick)}`; | ||||
|     }; | ||||
|  | ||||
|     // 3. Calcular el ancho del eje Y dinámicamente | ||||
|     const maxLabel = yAxisTickFormatter(dataMax); | ||||
|     // Calculamos un ancho base + un extra por cada carácter en la etiqueta más larga. | ||||
|     const dynamicWidth = mercado === 'EEUU' ? 5 + (maxLabel.length * 4.5) : 15 + (maxLabel.length * 5);  | ||||
|  | ||||
|     return ( | ||||
|         <ResponsiveContainer width="100%" height={300}> | ||||
|             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> | ||||
|                 <CartesianGrid strokeDasharray="3 3" /> | ||||
|                 <XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} /> | ||||
|                 <YAxis domain={['dataMin - 1', 'dataMax + 1']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> | ||||
|                 <Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio']} /> | ||||
|                 <YAxis | ||||
|                     domain={[domainMin, domainMax]} | ||||
|                     tickFormatter={yAxisTickFormatter} | ||||
|                     width={dynamicWidth} | ||||
|                     tickMargin={5} | ||||
|                 /> | ||||
|                 <Tooltip | ||||
|                     formatter={(value: number) => [`${formatCurrency2Decimal(value, mercado === 'EEUU' ? 'USD' : 'ARS')}`, 'Precio']} | ||||
|                     labelFormatter={formatTooltipLabel} | ||||
|                 /> | ||||
|                 <Legend /> | ||||
|                 <Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#028fbe" strokeWidth={2} dot={false} /> | ||||
|                 <Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#8884d8" strokeWidth={2} dot={false} /> | ||||
|             </LineChart> | ||||
|         </ResponsiveContainer> | ||||
|     ); | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { useState } from 'react'; | ||||
| import React, { useState, useRef } from 'react'; | ||||
| import { | ||||
|     Box, CircularProgress, Alert, Paper, Typography, Dialog, | ||||
|     DialogTitle, DialogContent, IconButton | ||||
| @@ -8,131 +8,159 @@ import ScaleIcon from '@mui/icons-material/Scale'; | ||||
| import { PiChartLineUpBold } from "react-icons/pi"; | ||||
| import CloseIcon from '@mui/icons-material/Close'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { formatCurrency, formatInteger } from '../utils/formatters'; | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||
| import { formatCurrency, formatInteger, formatDateOnly } from '../utils/formatters'; | ||||
| import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget'; | ||||
| import { HolidayAlert } from './common/HolidayAlert'; | ||||
|  | ||||
| // El subcomponente ahora tendrá un botón para el gráfico. | ||||
| const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: () => void }) => { | ||||
| /** | ||||
|  * Sub-componente para una única tarjeta de categoría de ganado. | ||||
|  */ | ||||
| const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: (event: React.MouseEvent<HTMLButtonElement>) => void }) => { | ||||
|     return ( | ||||
|         // Añadimos posición relativa para poder posicionar el botón del gráfico. | ||||
|         <Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative' }}> | ||||
|             <IconButton | ||||
|                 aria-label="ver historial" | ||||
|                 onClick={(e) => { | ||||
|                     e.stopPropagation(); | ||||
|                     onChartClick(); | ||||
|                 }} | ||||
|                 sx={{ | ||||
|                     position: 'absolute', | ||||
|                     top: 8, | ||||
|                     right: 8, | ||||
|                     backgroundColor: 'rgba(255, 255, 255, 0.7)', // Fondo semitransparente | ||||
|                     backdropFilter: 'blur(2px)', // Efecto "frosty glass" para el fondo | ||||
|                     border: '1px solid rgba(0, 0, 0, 0.1)', | ||||
|                     boxShadow: '0 2px 5px rgba(0,0,0,0.1)', | ||||
|                     transition: 'all 0.2s ease-in-out', // Transición suave para todos los cambios | ||||
|                     '&:hover': { | ||||
|                         transform: 'translateY(-2px)', // Se eleva un poco | ||||
|                         boxShadow: '0 4px 10px rgba(0,0,0,0.2)', // La sombra se hace más grande | ||||
|                         backgroundColor: 'rgba(255, 255, 255, 0.9)', | ||||
|                     } | ||||
|                 }} | ||||
|             > | ||||
|                 <PiChartLineUpBold size="20" /> | ||||
|             </IconButton> | ||||
|         <Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative', display: 'flex', flexDirection: 'column' }}> | ||||
|             {/* Contenido principal de la tarjeta */} | ||||
|             <Box sx={{ flexGrow: 1 }}> | ||||
|                 <IconButton | ||||
|                     aria-label="ver historial" | ||||
|                     onClick={onChartClick} | ||||
|                     sx={{ | ||||
|                         position: 'absolute', top: 8, right: 8, | ||||
|                         backgroundColor: 'rgba(255, 255, 255, 0.7)', | ||||
|                         backdropFilter: 'blur(2px)', | ||||
|                         border: '1px solid rgba(0, 0, 0, 0.1)', | ||||
|                         boxShadow: '0 2px 5px rgba(0,0,0,0.1)', | ||||
|                         transition: 'all 0.2s ease-in-out', | ||||
|                         '&:hover': { | ||||
|                             transform: 'translateY(-2px)', | ||||
|                             boxShadow: '0 4px 10px rgba(0,0,0,0.2)', | ||||
|                             backgroundColor: 'rgba(255, 255, 255, 0.9)', | ||||
|                         } | ||||
|                     }} | ||||
|                 > | ||||
|                     <PiChartLineUpBold size="20" /> | ||||
|                 </IconButton> | ||||
|  | ||||
|             <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2, pr: 5 /* Espacio para el botón */ }}> | ||||
|                 {registro.categoria} | ||||
|                 <Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography> | ||||
|             </Typography> | ||||
|                 <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2, pr: 5 }}> | ||||
|                     {registro.categoria} | ||||
|                     <Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography> | ||||
|                 </Typography> | ||||
|  | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||
|                 <Typography variant="body2" color="text.secondary">Precio Máximo:</Typography> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(registro.maximo)}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||
|                 <Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(registro.minimo)}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> | ||||
|                 <Typography variant="body2" color="text.secondary">Precio Mediano:</Typography> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography> | ||||
|             </Box> | ||||
|  | ||||
|             <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, pt: 1, borderTop: 1, borderColor: 'divider' }}> | ||||
|                 <Box sx={{ textAlign: 'center' }}> | ||||
|                     <PiCow size="28" /> | ||||
|                     <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.cabezas)}</Typography> | ||||
|                     <Typography variant="caption" color="text.secondary">Cabezas</Typography> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||
|                     <Typography variant="body2" color="text.secondary">Precio Máximo:</Typography> | ||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(registro.maximo)}</Typography> | ||||
|                 </Box> | ||||
|                 <Box sx={{ textAlign: 'center' }}> | ||||
|                     <ScaleIcon color="action" /> | ||||
|                     <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.kilosTotales)}</Typography> | ||||
|                     <Typography variant="caption" color="text.secondary">Kilos</Typography> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||
|                     <Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography> | ||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(registro.minimo)}</Typography> | ||||
|                 </Box> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> | ||||
|                     <Typography variant="body2" color="text.secondary">Precio Mediano:</Typography> | ||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|  | ||||
|             {/* Pie de la tarjeta */} | ||||
|             <Box sx={{ mt: 'auto', pt: 1, borderTop: 1, borderColor: 'divider' }}> | ||||
|                 <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}> | ||||
|                     <Box sx={{ textAlign: 'center' }}> | ||||
|                         <PiCow size="28" /> | ||||
|                         <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.cabezas)}</Typography> | ||||
|                         <Typography variant="caption" color="text.secondary">Cabezas</Typography> | ||||
|                     </Box> | ||||
|                     <Box sx={{ textAlign: 'center' }}> | ||||
|                         <ScaleIcon color="action" /> | ||||
|                         <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.kilosTotales)}</Typography> | ||||
|                         <Typography variant="caption" color="text.secondary">Kilos</Typography> | ||||
|                     </Box> | ||||
|                 </Box> | ||||
|  | ||||
|                 <Typography variant="caption" sx={{ display: 'block', textAlign: 'left', color: 'text.secondary', mt: 1, pt: 1, borderTop: 1, borderColor: 'divider' }}> | ||||
|                     {formatDateOnly(registro.fechaRegistro)} | ||||
|                 </Typography> | ||||
|             </Box> | ||||
|         </Paper> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Widget autónomo para las tarjetas de resumen del Mercado Agroganadero. | ||||
|  */ | ||||
| export const MercadoAgroCardWidget = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|     const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null); | ||||
|     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|     const isHoliday = useIsHoliday('BA'); | ||||
|  | ||||
|     const handleChartClick = (registro: CotizacionGanado) => { | ||||
|     const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null); | ||||
|     const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||
|  | ||||
|     const handleChartClick = (registro: CotizacionGanado, event: React.MouseEvent<HTMLButtonElement>) => { | ||||
|         triggerButtonRef.current = event.currentTarget; | ||||
|         setSelectedCategory(registro); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseDialog = () => { | ||||
|         setSelectedCategory(null); | ||||
|         setTimeout(() => { | ||||
|             triggerButtonRef.current?.focus(); | ||||
|         }, 0); | ||||
|     }; | ||||
|  | ||||
|     if (loading) { | ||||
|     const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|     if (isLoading) { | ||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|     } | ||||
|     if (error) { | ||||
|         return <Alert severity="error">{error}</Alert>; | ||||
|  | ||||
|     if (dataError) { | ||||
|         return <Alert severity="error">{dataError}</Alert>; | ||||
|     } | ||||
|  | ||||
|     if (!data || data.length === 0) { | ||||
|         if (isHoliday) { | ||||
|             return <HolidayAlert />; | ||||
|         } | ||||
|         return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}> | ||||
|             {isHoliday && ( | ||||
|                 <Box sx={{ mb: 2 }}> | ||||
|                     <HolidayAlert /> | ||||
|                 </Box> | ||||
|             )} | ||||
|  | ||||
|             <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: { xs: 2, sm: 2, md: 3 }, justifyContent: 'center' }}> | ||||
|                 {data.map(registro => ( | ||||
|                     <AgroCard key={registro.id} registro={registro} onChartClick={() => handleChartClick(registro)} /> | ||||
|                     <AgroCard key={registro.id} registro={registro} onChartClick={(event) => handleChartClick(registro, event)} /> | ||||
|                 ))} | ||||
|             </Box> | ||||
|  | ||||
|             <Dialog | ||||
|                 open={Boolean(selectedCategory)} | ||||
|                 onClose={handleCloseDialog} | ||||
|                 maxWidth="md" | ||||
|                 fullWidth | ||||
|                 sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} // Permite que el botón se vea fuera | ||||
|                 sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} | ||||
|             > | ||||
|                 <IconButton | ||||
|                     aria-label="close" | ||||
|                     onClick={handleCloseDialog} | ||||
|                     sx={{ | ||||
|                         position: 'absolute', | ||||
|                         top: -15, // Mueve el botón hacia arriba, fuera del Dialog | ||||
|                         right: -15, // Mueve el botón hacia la derecha, fuera del Dialog | ||||
|                         position: 'absolute', top: -15, right: -15, | ||||
|                         color: (theme) => theme.palette.grey[500], | ||||
|                         backgroundColor: 'white', | ||||
|                         boxShadow: 3, // Añade una sombra para que destaque | ||||
|                         '&:hover': { | ||||
|                             backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse | ||||
|                         }, | ||||
|                         backgroundColor: 'white', boxShadow: 3, | ||||
|                         '&:hover': { backgroundColor: 'grey.100' }, | ||||
|                     }} | ||||
|                 > | ||||
|                     <CloseIcon /> | ||||
|                 </IconButton> | ||||
|  | ||||
|                 <DialogTitle sx={{ m: 0, p: 2 }}> | ||||
|                     Mensual de {selectedCategory?.categoria} ({selectedCategory?.especificaciones}) | ||||
|                     Historial de 30 días para {selectedCategory?.categoria} ({selectedCategory?.especificaciones}) | ||||
|                 </DialogTitle> | ||||
|                 <DialogContent dividers> | ||||
|                     {selectedCategory && ( | ||||
|   | ||||
| @@ -2,13 +2,17 @@ import { | ||||
|   Box, CircularProgress, Alert, Table, TableBody, TableCell, | ||||
|   TableContainer, TableHead, TableRow, Paper, Typography, Tooltip | ||||
| } from '@mui/material'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { formatCurrency, formatInteger, formatFullDateTime } from '../utils/formatters'; | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||
| import { formatCurrency, formatInteger, formatDateOnly } from '../utils/formatters'; | ||||
| import { HolidayAlert } from './common/HolidayAlert'; | ||||
|  | ||||
| // --- V INICIO DE LA MODIFICACIÓN V --- | ||||
| // El sub-componente ahora solo necesita renderizar la tarjeta de móvil. | ||||
| // La fila de la tabla la haremos directamente en el componente principal. | ||||
| /** | ||||
|  * Sub-componente para renderizar cada registro como una tarjeta en la vista móvil. | ||||
|  */ | ||||
| const AgroDataCard = ({ row }: { row: CotizacionGanado }) => { | ||||
|     const commonStyles = { | ||||
|         cell: { | ||||
| @@ -56,18 +60,42 @@ const AgroDataCard = ({ row }: { row: CotizacionGanado }) => { | ||||
|         </Paper> | ||||
|     ); | ||||
| }; | ||||
| // --- ^ FIN DE LA MODIFICACIÓN ^ --- | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Widget autónomo para la tabla/lista responsiva del Mercado Agroganadero. | ||||
|  */ | ||||
| export const MercadoAgroWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|   // Hooks para obtener los datos y el estado de feriado. | ||||
|   const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|   const isHoliday = useIsHoliday('BA'); | ||||
|  | ||||
|   if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; } | ||||
|   if (error) { return <Alert severity="error">{error}</Alert>; } | ||||
|   if (!data || data.length === 0) { return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; } | ||||
|   // Estado de carga unificado. | ||||
|   const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|   if (isLoading) { | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|   } | ||||
|  | ||||
|   if (dataError) { | ||||
|     return <Alert severity="error">{dataError}</Alert>; | ||||
|   } | ||||
|  | ||||
|   if (!data || data.length === 0) { | ||||
|     if (isHoliday) { | ||||
|       return <HolidayAlert />; | ||||
|     } | ||||
|     return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box>  | ||||
|     <Box> | ||||
|       {/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */} | ||||
|       {isHoliday && ( | ||||
|         <Box sx={{ mb: 2 }}> | ||||
|           <HolidayAlert /> | ||||
|         </Box> | ||||
|       )} | ||||
|  | ||||
|       {/* VISTA DE ESCRITORIO (se oculta en móvil) */} | ||||
|       <TableContainer component={Paper} sx={{ display: { xs: 'none', md: 'block' } }}> | ||||
|         <Table size="small" aria-label="tabla mercado agroganadero"> | ||||
| @@ -107,9 +135,10 @@ export const MercadoAgroWidget = () => { | ||||
|           ))} | ||||
|       </Box> | ||||
|  | ||||
|       <Tooltip title={`Última actualización: ${formatFullDateTime(data[0].fechaRegistro)}`}> | ||||
|       {/* La información de la fuente se muestra siempre, usando la fecha del primer registro */} | ||||
|       <Tooltip title={`Última actualización: ${formatDateOnly(data[0].fechaRegistro)}`}> | ||||
|         <Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}> | ||||
|           Fuente: Mercado Agroganadero S.A. | ||||
|           {formatDateOnly(data[0].fechaRegistro)} - Fuente: Mercado Agroganadero S.A. | ||||
|         </Typography> | ||||
|       </Tooltip> | ||||
|     </Box> | ||||
|   | ||||
| @@ -5,13 +5,17 @@ import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
|  | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { formatInteger, formatCurrency } from '../utils/formatters'; // <-- CORREGIDO: necesitamos formatCurrency | ||||
| import { formatCurrency2Decimal, formatCurrency } from '../utils/formatters'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; // <-- Importamos el hook | ||||
| import { HolidayAlert } from './common/HolidayAlert';   // <-- Importamos la alerta | ||||
|  | ||||
| // --- V SUB-COMPONENTE AÑADIDO V --- | ||||
| /** | ||||
|  * Sub-componente para la variación del índice. | ||||
|  */ | ||||
| const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: number }) => { | ||||
|     if (anterior === 0) return null; // Evitar división por cero | ||||
|     if (anterior === 0) return null; | ||||
|     const variacionPuntos = actual - anterior; | ||||
|     const variacionPorcentaje = (variacionPuntos / anterior) * 100; | ||||
|  | ||||
| @@ -34,44 +38,79 @@ const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: numbe | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
| // --- ^ SUB-COMPONENTE AÑADIDO ^ --- | ||||
|  | ||||
| /** | ||||
|  * Widget autónomo para la tarjeta de héroe del S&P Merval. | ||||
|  */ | ||||
| export const MervalHeroCard = () => { | ||||
|     const { data: allLocalData, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|     // Cada widget gestiona sus propias llamadas a la API | ||||
|     const { data: allLocalData, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|     const isHoliday = useIsHoliday('BA'); | ||||
|      | ||||
|     // Estado interno para el gráfico | ||||
|     const [dias, setDias] = useState<number>(30); | ||||
|  | ||||
|     const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => { | ||||
|         if (nuevoRango !== null) { setDias(nuevoRango); } | ||||
|     }; | ||||
|      | ||||
|     // Filtramos el dato específico que este widget necesita | ||||
|     const mervalData = allLocalData?.find(d => d.ticker === '^MERV'); | ||||
|      | ||||
|     // --- LÓGICA DE RENDERIZADO CORREGIDA --- | ||||
|      | ||||
|     // El estado de carga depende de AMBAS llamadas a la API. | ||||
|     const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|     if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; } | ||||
|     if (error) { return <Alert severity="error">{error}</Alert>; } | ||||
|     if (!mervalData) { return <Alert severity="info">No se encontraron datos para el índice MERVAL.</Alert>; } | ||||
|     if (isLoading) { | ||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: '288px' }}><CircularProgress /></Box>; | ||||
|     } | ||||
|  | ||||
|     if (dataError) { | ||||
|         return <Alert severity="error">{dataError}</Alert>; | ||||
|     } | ||||
|  | ||||
|     // Si no hay datos del Merval, es un estado final. | ||||
|     if (!mervalData) { | ||||
|         // Si no hay datos PERO sabemos que es feriado, la alerta de feriado es más informativa. | ||||
|         if (isHoliday) { | ||||
|             return <HolidayAlert />; | ||||
|         } | ||||
|         return <Alert severity="info">No se encontraron datos para el índice MERVAL.</Alert>; | ||||
|     } | ||||
|  | ||||
|     // Si llegamos aquí, SÍ tenemos datos para mostrar. | ||||
|     return ( | ||||
|         <Paper elevation={3} sx={{ p: 2, mb: 3 }}> | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> | ||||
|                 <Box> | ||||
|                     <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>Índice S&P MERVAL</Typography> | ||||
|                     <Typography variant="h3" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(mervalData.precioActual)}</Typography> | ||||
|         <Box> | ||||
|             {/* Si es feriado, mostramos la alerta como un AVISO encima del contenido. */} | ||||
|             {isHoliday && ( | ||||
|                 <Box sx={{ mb: 2 }}> | ||||
|                     <HolidayAlert /> | ||||
|                 </Box> | ||||
|                 <Box sx={{ pt: 2 }}> | ||||
|                     {/* Ahora sí encontrará el componente */} | ||||
|                     <VariacionMerval actual={mervalData.precioActual} anterior={mervalData.cierreAnterior} /> | ||||
|             )} | ||||
|  | ||||
|             {/* El contenido principal del widget siempre se muestra si hay datos. */} | ||||
|             <Paper elevation={3} sx={{ p: 2, mb: 3 }}> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> | ||||
|                     <Box> | ||||
|                         <Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>Índice S&P MERVAL</Typography> | ||||
|                         <Typography variant="h4" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatCurrency2Decimal(mervalData.precioActual)}</Typography> | ||||
|                     </Box> | ||||
|                     <Box sx={{ pt: 2 }}> | ||||
|                         <VariacionMerval actual={mervalData.precioActual} anterior={mervalData.cierreAnterior} /> | ||||
|                     </Box> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|             <Box sx={{ mt: 2 }}> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> | ||||
|                     <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> | ||||
|                         <ToggleButton value={7}>Semanal</ToggleButton> | ||||
|                         <ToggleButton value={30}>Mensual</ToggleButton> | ||||
|                         <ToggleButton value={365}>Anual</ToggleButton> | ||||
|                     </ToggleButtonGroup> | ||||
|                 <Box sx={{ mt: 2 }}> | ||||
|                     <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> | ||||
|                         <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> | ||||
|                             <ToggleButton value={7}>Semanal</ToggleButton> | ||||
|                             <ToggleButton value={30}>Mensual</ToggleButton> | ||||
|                             <ToggleButton value={365}>Anual</ToggleButton> | ||||
|                         </ToggleButtonGroup> | ||||
|                     </Box> | ||||
|                     <HistoricalChartWidget ticker={mervalData.ticker} mercado="Local" dias={dias} /> | ||||
|                 </Box> | ||||
|                 <HistoricalChartWidget ticker={mervalData.ticker} mercado="Local" dias={dias} /> | ||||
|             </Box> | ||||
|         </Paper> | ||||
|             </Paper> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
| @@ -1,13 +1,20 @@ | ||||
| import { useState } from 'react'; | ||||
| import React, { useState } from 'react'; | ||||
| import { Box, Paper, Typography, ToggleButton, ToggleButtonGroup, CircularProgress, Alert } from '@mui/material'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||
| import { formatInteger } from '../utils/formatters'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
| import { HolidayAlert } from './common/HolidayAlert'; | ||||
|  | ||||
| /** | ||||
|  * Sub-componente interno para mostrar la variación del índice. | ||||
|  */ | ||||
| const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number }) => { | ||||
|     if (anterior === 0) return null; | ||||
|     const variacionPuntos = actual - anterior; | ||||
| @@ -33,41 +40,74 @@ const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Widget autónomo para la tarjeta de héroe del S&P 500. | ||||
|  */ | ||||
| export const UsaIndexHeroCard = () => { | ||||
|     const { data: allUsaData, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); | ||||
|     // Hooks para obtener los datos y el estado de feriado para el mercado de EEUU. | ||||
|     const { data: allUsaData, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); | ||||
|     const isHoliday = useIsHoliday('US'); | ||||
|  | ||||
|     // Estado interno para el gráfico. | ||||
|     const [dias, setDias] = useState<number>(30); | ||||
|  | ||||
|     const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => { | ||||
|         if (nuevoRango !== null) { setDias(nuevoRango); } | ||||
|     }; | ||||
|      | ||||
|     // Filtramos el dato específico que este widget necesita. | ||||
|     const indexData = allUsaData?.find(d => d.ticker === '^GSPC'); | ||||
|  | ||||
|     if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; } | ||||
|     if (error) { return <Alert severity="error">{error}</Alert>; } | ||||
|     if (!indexData) { return <Alert severity="info">No se encontraron datos para el índice S&P 500.</Alert>; } | ||||
|     // Estado de carga unificado: esperamos a que AMBAS llamadas a la API terminen. | ||||
|     const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|     if (isLoading) { | ||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4, height: { xs: 'auto', md: '445px' } }}><CircularProgress /></Box>; | ||||
|     } | ||||
|  | ||||
|     if (dataError) { | ||||
|         return <Alert severity="error">{dataError}</Alert>; | ||||
|     } | ||||
|  | ||||
|     // Si no hay datos del S&P 500, mostramos el mensaje apropiado. | ||||
|     if (!indexData) { | ||||
|         if (isHoliday) { | ||||
|             return <HolidayAlert />; | ||||
|         } | ||||
|         return <Alert severity="info">No se encontraron datos para el índice S&P 500.</Alert>; | ||||
|     } | ||||
|  | ||||
|     // Si hay datos, renderizamos el contenido completo. | ||||
|     return ( | ||||
|         <Paper elevation={3} sx={{ p: 2, mb: 3 }}> | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> | ||||
|                 <Box> | ||||
|                     <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>S&P 500 Index</Typography> | ||||
|                     <Typography variant="h3" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(indexData.precioActual)}</Typography> | ||||
|         <Box> | ||||
|             {/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */} | ||||
|             {isHoliday && ( | ||||
|                 <Box sx={{ mb: 2 }}> | ||||
|                     <HolidayAlert /> | ||||
|                 </Box> | ||||
|                 <Box sx={{ pt: 2 }}> | ||||
|                     <VariacionIndex actual={indexData.precioActual} anterior={indexData.cierreAnterior} /> | ||||
|             )} | ||||
|  | ||||
|             <Paper elevation={3} sx={{ p: 2, mb: 3 }}> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> | ||||
|                     <Box> | ||||
|                         <Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>S&P 500 Index</Typography> | ||||
|                         <Typography variant="h4" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(indexData.precioActual)}</Typography> | ||||
|                     </Box> | ||||
|                     <Box sx={{ pt: 2 }}> | ||||
|                         <VariacionIndex actual={indexData.precioActual} anterior={indexData.cierreAnterior} /> | ||||
|                     </Box> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|             <Box sx={{ mt: 2 }}> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> | ||||
|                     <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> | ||||
|                         <ToggleButton value={7}>Semanal</ToggleButton> | ||||
|                         <ToggleButton value={30}>Mensual</ToggleButton> | ||||
|                         <ToggleButton value={365}>Anual</ToggleButton> | ||||
|                     </ToggleButtonGroup> | ||||
|                 <Box sx={{ mt: 2 }}> | ||||
|                     <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> | ||||
|                         <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> | ||||
|                             <ToggleButton value={7}>Semanal</ToggleButton> | ||||
|                             <ToggleButton value={30}>Mensual</ToggleButton> | ||||
|                             <ToggleButton value={365}>Anual</ToggleButton> | ||||
|                         </ToggleButtonGroup> | ||||
|                     </Box> | ||||
|                     <HistoricalChartWidget ticker={indexData.ticker} mercado="EEUU" dias={dias} /> | ||||
|                 </Box> | ||||
|                 <HistoricalChartWidget ticker={indexData.ticker} mercado="EEUU" dias={dias} /> | ||||
|             </Box> | ||||
|         </Paper> | ||||
|             </Paper> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										16
									
								
								frontend/src/components/common/HolidayAlert.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								frontend/src/components/common/HolidayAlert.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import { Alert } from '@mui/material'; | ||||
| import CelebrationIcon from '@mui/icons-material/Celebration'; | ||||
| import { formatDateOnly } from '../../utils/formatters'; | ||||
|  | ||||
| export const HolidayAlert = () => { | ||||
|     // Obtener la fecha actual en la zona horaria de Buenos Aires | ||||
|     const nowInBuenosAires = new Date( | ||||
|         new Date().toLocaleString('en-US', { timeZone: 'America/Argentina/Buenos_Aires' }) | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
|         <Alert severity="info" icon={<CelebrationIcon fontSize="inherit" />}> | ||||
|             {formatDateOnly(nowInBuenosAires.toISOString())} Mercado cerrado por feriado. | ||||
|         </Alert> | ||||
|     ); | ||||
| }; | ||||
| @@ -1,11 +1,17 @@ | ||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | ||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import { useApiData } from '../../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||
| import type { CotizacionGanado } from '../../models/mercadoModels'; | ||||
| import { formatInteger, formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||
| import { HolidayAlert } from '../common/HolidayAlert'; | ||||
|  | ||||
| // Función para convertir datos a formato CSV | ||||
| /** | ||||
|  * Función para convertir los datos de la tabla a un formato CSV para el portapapeles. | ||||
|  */ | ||||
| const toCSV = (headers: string[], data: CotizacionGanado[]) => { | ||||
|     const headerRow = headers.join(';'); | ||||
|     const dataRows = data.map(row =>  | ||||
| @@ -17,18 +23,25 @@ const toCSV = (headers: string[], data: CotizacionGanado[]) => { | ||||
|             formatCurrency(row.mediano), | ||||
|             formatInteger(row.cabezas), | ||||
|             formatInteger(row.kilosTotales), | ||||
|             formatInteger(row.importeTotal) | ||||
|             formatInteger(row.importeTotal), | ||||
|             formatFullDateTime(row.fechaRegistro) | ||||
|         ].join(';') | ||||
|     ); | ||||
|     return [headerRow, ...dataRows].join('\n'); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Componente de tabla de datos crudos para el Mercado Agroganadero, | ||||
|  * diseñado para la página de redacción. | ||||
|  */ | ||||
| export const RawAgroTable = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|     // Hooks para obtener los datos y el estado de feriado. | ||||
|     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|     const isHoliday = useIsHoliday('BA'); | ||||
|  | ||||
|     const handleCopy = () => { | ||||
|         if (!data) return; | ||||
|         const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total"]; | ||||
|         const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total", "Fecha de Registro"]; | ||||
|         const csvData = toCSV(headers, data); | ||||
|          | ||||
|         copyToClipboard(csvData) | ||||
| @@ -39,12 +52,28 @@ export const RawAgroTable = () => { | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (!data) return null; | ||||
|     // Estado de carga unificado. | ||||
|     const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|     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 del mercado agroganadero disponibles.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             {/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} | ||||
|             {isHoliday && ( | ||||
|                 <Box sx={{ mb: 2 }}> | ||||
|                     <HolidayAlert /> | ||||
|                 </Box> | ||||
|             )} | ||||
|  | ||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> | ||||
|                 Copiar como CSV | ||||
|             </Button> | ||||
|   | ||||
| @@ -1,11 +1,17 @@ | ||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | ||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import { useApiData } from '../../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||
| import type { CotizacionBolsa } from '../../models/mercadoModels'; | ||||
| import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||
| import { HolidayAlert } from '../common/HolidayAlert'; | ||||
|  | ||||
| // Función para convertir datos a formato CSV | ||||
| /** | ||||
|  * Función para convertir los datos de la tabla a formato CSV. | ||||
|  */ | ||||
| const toCSV = (headers: string[], data: CotizacionBolsa[]) => { | ||||
|     const headerRow = headers.join(';'); | ||||
|     const dataRows = data.map(row =>  | ||||
| @@ -14,36 +20,57 @@ const toCSV = (headers: string[], data: CotizacionBolsa[]) => { | ||||
|             row.nombreEmpresa, | ||||
|             formatCurrency(row.precioActual), | ||||
|             formatCurrency(row.cierreAnterior), | ||||
|             `${row.porcentajeCambio.toFixed(2)}%` | ||||
|             `${row.porcentajeCambio.toFixed(2)}%`, | ||||
|             formatFullDateTime(row.fechaRegistro) | ||||
|         ].join(';') | ||||
|     ); | ||||
|     return [headerRow, ...dataRows].join('\n'); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Componente de tabla de datos crudos para la Bolsa Local (MERVAL y acciones), | ||||
|  * diseñado para la página de redacción. | ||||
|  */ | ||||
| export const RawBolsaLocalTable = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|     // Hooks para obtener los datos y el estado de feriado. | ||||
|     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|     const isHoliday = useIsHoliday('BA'); | ||||
|  | ||||
|     const handleCopy = () => { | ||||
|         if (!data) return; | ||||
|         const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %"]; | ||||
|         const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %", "Fecha de Registro"]; | ||||
|         const csvData = toCSV(headers, data); | ||||
|          | ||||
|         copyToClipboard(csvData) | ||||
|             .then(() => { | ||||
|                 alert('¡Tabla copiada al portapapeles!'); | ||||
|             }) | ||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) | ||||
|             .catch(err => { | ||||
|                 console.error('Error al copiar:', err); | ||||
|                 alert('Error: No se pudo copiar la tabla.'); | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (!data) return null; | ||||
|     // Estado de carga unificado. | ||||
|     const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|     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 ( | ||||
|         <Box> | ||||
|             {/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} | ||||
|             {isHoliday && ( | ||||
|                 <Box sx={{ mb: 2 }}> | ||||
|                     <HolidayAlert /> | ||||
|                 </Box> | ||||
|             )} | ||||
|  | ||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> | ||||
|                 Copiar como CSV | ||||
|             </Button> | ||||
|   | ||||
| @@ -1,11 +1,17 @@ | ||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | ||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import { useApiData } from '../../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||
| import type { CotizacionBolsa } from '../../models/mercadoModels'; | ||||
| import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||
| import { HolidayAlert } from '../common/HolidayAlert'; | ||||
|  | ||||
| // Función para convertir datos a formato CSV | ||||
| /** | ||||
|  * Función para convertir los datos de la tabla a formato CSV. | ||||
|  */ | ||||
| const toCSV = (headers: string[], data: CotizacionBolsa[]) => { | ||||
|     const headerRow = headers.join(';'); | ||||
|     const dataRows = data.map(row =>  | ||||
| @@ -14,18 +20,25 @@ const toCSV = (headers: string[], data: CotizacionBolsa[]) => { | ||||
|             row.nombreEmpresa, | ||||
|             formatCurrency(row.precioActual, 'USD'), | ||||
|             formatCurrency(row.cierreAnterior, 'USD'), | ||||
|             `${row.porcentajeCambio.toFixed(2)}%` | ||||
|             `${row.porcentajeCambio.toFixed(2)}%`, | ||||
|             formatFullDateTime(row.fechaRegistro) | ||||
|         ].join(';') | ||||
|     ); | ||||
|     return [headerRow, ...dataRows].join('\n'); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs, | ||||
|  * diseñado para la página de redacción. | ||||
|  */ | ||||
| export const RawBolsaUsaTable = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); | ||||
|     // 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 isHoliday = useIsHoliday('US'); | ||||
|  | ||||
|     const handleCopy = () => { | ||||
|         if (!data) return; | ||||
|         const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %"]; | ||||
|         const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %", "Fecha de Registro"]; | ||||
|         const csvData = toCSV(headers, data); | ||||
|          | ||||
|         copyToClipboard(csvData) | ||||
| @@ -36,12 +49,28 @@ export const RawBolsaUsaTable = () => { | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (!data || data.length === 0) return <Alert severity="info">No hay datos disponibles (el fetcher puede estar desactivado).</Alert>; | ||||
|     // Estado de carga unificado. | ||||
|     const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|     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 de EEUU (el fetcher puede estar desactivado).</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             {/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} | ||||
|             {isHoliday && ( | ||||
|                 <Box sx={{ mb: 2 }}> | ||||
|                     <HolidayAlert /> | ||||
|                 </Box> | ||||
|             )} | ||||
|  | ||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> | ||||
|                 Copiar como CSV | ||||
|             </Button> | ||||
| @@ -62,8 +91,8 @@ export const RawBolsaUsaTable = () => { | ||||
|                             <TableRow key={row.id}> | ||||
|                                 <TableCell>{row.ticker}</TableCell> | ||||
|                                 <TableCell>{row.nombreEmpresa}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.precioActual, 'USD')}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.cierreAnterior, 'USD')}</TableCell> | ||||
|                                 <TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell> | ||||
|                                 <TableCell align="right">{formatCurrency(row.cierreAnterior, 'USD')}</TableCell> | ||||
|                                 <TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell> | ||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> | ||||
|                             </TableRow> | ||||
|   | ||||
| @@ -1,11 +1,17 @@ | ||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | ||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import { useApiData } from '../../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||
| import type { CotizacionGrano } from '../../models/mercadoModels'; | ||||
| import { formatInteger, formatDateOnly, formatFullDateTime } from '../../utils/formatters'; | ||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||
| import { HolidayAlert } from '../common/HolidayAlert'; | ||||
|  | ||||
| // Función para convertir datos a formato CSV | ||||
| /** | ||||
|  * Función para convertir los datos de la tabla a formato CSV. | ||||
|  */ | ||||
| const toCSV = (headers: string[], data: CotizacionGrano[]) => { | ||||
|     const headerRow = headers.join(';'); | ||||
|     const dataRows = data.map(row =>  | ||||
| @@ -13,18 +19,25 @@ const toCSV = (headers: string[], data: CotizacionGrano[]) => { | ||||
|             row.nombre, | ||||
|             formatInteger(row.precio), | ||||
|             formatInteger(row.variacionPrecio), | ||||
|             formatDateOnly(row.fechaOperacion) | ||||
|             formatDateOnly(row.fechaOperacion), | ||||
|             formatFullDateTime(row.fechaRegistro) | ||||
|         ].join(';') | ||||
|     ); | ||||
|     return [headerRow, ...dataRows].join('\n'); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Componente de tabla de datos crudos para el mercado de Granos, | ||||
|  * diseñado para la página de redacción. | ||||
|  */ | ||||
| export const RawGranosTable = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos'); | ||||
|     // 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 isHoliday = useIsHoliday('BA'); | ||||
|  | ||||
|     const handleCopy = () => { | ||||
|         if (!data) return; | ||||
|         const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op."]; | ||||
|         const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op.", "Fecha de Registro"]; | ||||
|         const csvData = toCSV(headers, data); | ||||
|          | ||||
|         copyToClipboard(csvData) | ||||
| @@ -35,12 +48,28 @@ export const RawGranosTable = () => { | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (!data) return null; | ||||
|     // Estado de carga unificado. | ||||
|     const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|     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 de granos disponibles.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             {/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} | ||||
|             {isHoliday && ( | ||||
|                 <Box sx={{ mb: 2 }}> | ||||
|                     <HolidayAlert /> | ||||
|                 </Box> | ||||
|             )} | ||||
|  | ||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> | ||||
|                 Copiar como CSV | ||||
|             </Button> | ||||
|   | ||||
| @@ -2,8 +2,10 @@ import { useState, useEffect, useCallback } from 'react'; | ||||
| import apiClient from '../api/apiClient'; | ||||
| import { AxiosError } from 'axios'; | ||||
|  | ||||
| // T es el tipo de dato que esperamos de la API (ej. CotizacionBolsa[]) | ||||
| export function useApiData<T>(url: string) { | ||||
| // Definimos la URL de la API en un solo lugar y de forma explícita. | ||||
| const API_ROOT = 'https://widgets.eldia.com/api'; | ||||
|  | ||||
| export function useApiData<T>(endpoint: string) { | ||||
|   const [data, setData] = useState<T | null>(null); | ||||
|   const [loading, setLoading] = useState<boolean>(true); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
| @@ -12,11 +14,13 @@ export function useApiData<T>(url: string) { | ||||
|     setLoading(true); | ||||
|     setError(null); | ||||
|     try { | ||||
|       const response = await apiClient.get<T>(url); | ||||
|       // Construimos la URL completa y absoluta para la llamada. | ||||
|       const fullUrl = `${API_ROOT}${endpoint}`; | ||||
|       const response = await apiClient.get<T>(fullUrl); | ||||
|       setData(response.data); | ||||
|     } catch (err) { | ||||
|       if (err instanceof AxiosError) { | ||||
|         setError(`Error al cargar datos: ${err.message}`); | ||||
|         setError(`Error de red o de la API: ${err.message}`); | ||||
|       } else { | ||||
|         setError('Ocurrió un error inesperado.'); | ||||
|       } | ||||
| @@ -24,7 +28,7 @@ export function useApiData<T>(url: string) { | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }, [url]); | ||||
|   }, [endpoint]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     fetchData(); | ||||
|   | ||||
							
								
								
									
										21
									
								
								frontend/src/hooks/useIsHoliday.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								frontend/src/hooks/useIsHoliday.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import { useApiData } from './useApiData'; | ||||
|  | ||||
| export function useIsHoliday(marketCode: 'BA' | 'US') { | ||||
|   // Reutilizamos el hook que ya sabe cómo obtener datos de nuestra API. | ||||
|   // Le pasamos el endpoint específico para los feriados. | ||||
|   const { data: isHoliday, loading, error } = useApiData<boolean>(`/mercados/es-feriado/${marketCode}`); | ||||
|  | ||||
|   // Si hay un error al cargar los feriados, por seguridad asumimos que no es feriado. | ||||
|   if (error) { | ||||
|     console.error(`Error al verificar feriado para ${marketCode}, asumiendo que no lo es.`, error); | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   // Si está cargando, devolvemos null para que el componente sepa que debe esperar. | ||||
|   if (loading) { | ||||
|     return null; | ||||
|   } | ||||
|    | ||||
|   // Devolvemos el dato booleano que llegó de la API. | ||||
|   return isHoliday; | ||||
| } | ||||
| @@ -31,7 +31,7 @@ const widgetRegistry = { | ||||
|   'mercado-agro-tarjetas': MercadoAgroCardWidget, | ||||
|   'mercado-agro-tabla': MercadoAgroWidget, | ||||
|    | ||||
|   // Página completa como un widget | ||||
|   // Widget Página datos crudos | ||||
|   'pagina-datos-crudos': RawDataView, | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -11,6 +11,15 @@ export const formatCurrency = (num: number, currency = 'ARS') => { | ||||
|   }).format(num); | ||||
| }; | ||||
|  | ||||
| export const formatCurrency2Decimal = (num: number, currency = 'ARS') => {   | ||||
|   return new Intl.NumberFormat('es-AR', { | ||||
|     style: 'decimal', | ||||
|     currency: currency, | ||||
|     minimumFractionDigits: 2, | ||||
|     maximumFractionDigits: 2, | ||||
|   }).format(num); | ||||
| }; | ||||
|  | ||||
| export const formatInteger = (num: number) => { | ||||
|   return new Intl.NumberFormat('es-AR').format(num); | ||||
| }; | ||||
| @@ -23,7 +32,7 @@ export const formatFullDateTime = (dateString: string) => { | ||||
|     year: 'numeric', | ||||
|     hour: '2-digit', | ||||
|     minute: '2-digit', | ||||
|     hourCycle: 'h23', // <--- LA CLAVE PARA EL FORMATO 24HS | ||||
|     hourCycle: 'h23', | ||||
|     timeZone: 'America/Argentina/Buenos_Aires', | ||||
|   }); | ||||
| }; | ||||
|   | ||||
| @@ -1,33 +1,24 @@ | ||||
| import { defineConfig } from 'vite' | ||||
| import react from '@vitejs/plugin-react' | ||||
| import path from 'path'; // Importa el módulo 'path' de Node | ||||
|  | ||||
| // https://vite.dev/config/ | ||||
| // frontend/vite.config.ts | ||||
| export default defineConfig({ | ||||
|   plugins: [react()], | ||||
|   // --- V INICIO DE LA CONFIGURACIÓN DE LIBRERÍA V --- | ||||
|   build: { | ||||
|     lib: { | ||||
|       // La entrada a nuestra librería. Apunta a nuestro main.tsx | ||||
|       entry: path.resolve(__dirname, 'src/main.tsx'), | ||||
|       // El nombre de la variable global que se expondrá | ||||
|       name: 'MercadosWidgets', | ||||
|       // El nombre del archivo de salida | ||||
|       fileName: (format) => `mercados-widgets.${format}.js`, | ||||
|     }, | ||||
|     // No necesitamos minificar el CSS si es simple, pero es buena práctica | ||||
|     cssCodeSplit: true, | ||||
|     // Generar un manifest para saber qué archivos se crearon | ||||
|     manifest: true, | ||||
|     rollupOptions: { | ||||
|       // Asegúrate de no externalizar React, para que se incluya en el bundle | ||||
|       external: [], | ||||
|       output: { | ||||
|         globals: { | ||||
|           react: 'React', | ||||
|           'react-dom': 'ReactDOM' | ||||
|         } | ||||
|       } | ||||
|     outDir: 'dist', | ||||
|     manifest: 'manifest.json', // Esto asegura que se llame 'manifest.json' y esté en la raíz de 'dist' | ||||
|   }, | ||||
|   server: { | ||||
|     proxy: { | ||||
|       // Cualquier petición que empiece con /api... | ||||
|       '/api': { | ||||
|         // ...redirígela a nuestro backend de .NET | ||||
|         target: 'http://localhost:5045', | ||||
|         // Cambia el origen de la petición para que el backend la acepte | ||||
|         changeOrigin: true, | ||||
|         // No necesitamos reescribir la ruta, ya que el backend espera /api/... | ||||
|         // rewrite: (path) => path.replace(/^\/api/, '')  | ||||
|       }, | ||||
|     } | ||||
|   } | ||||
| }) | ||||
| @@ -1,9 +1,13 @@ | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Mercados.Infrastructure.Services; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace Mercados.Api.Controllers | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Controlador principal para exponer los datos de los mercados financieros. | ||||
|     /// </summary> | ||||
|     [ApiController] | ||||
|     [Route("api/[controller]")] | ||||
|     public class MercadosController : ControllerBase | ||||
| @@ -11,22 +15,35 @@ namespace Mercados.Api.Controllers | ||||
|         private readonly ICotizacionBolsaRepository _bolsaRepo; | ||||
|         private readonly ICotizacionGranoRepository _granoRepo; | ||||
|         private readonly ICotizacionGanadoRepository _ganadoRepo; | ||||
|         private readonly IHolidayService _holidayService; | ||||
|         private readonly ILogger<MercadosController> _logger; | ||||
|  | ||||
|         // Inyectamos TODOS los repositorios que necesita el controlador. | ||||
|         /// <summary> | ||||
|         /// 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( | ||||
|             ICotizacionBolsaRepository bolsaRepo, | ||||
|             ICotizacionGranoRepository granoRepo, | ||||
|             ICotizacionGanadoRepository ganadoRepo, | ||||
|             IHolidayService holidayService, | ||||
|             ILogger<MercadosController> logger) | ||||
|         { | ||||
|             _bolsaRepo = bolsaRepo; | ||||
|             _granoRepo = granoRepo; | ||||
|             _ganadoRepo = ganadoRepo; | ||||
|             _holidayService = holidayService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         // --- Endpoint para Agroganadero --- | ||||
|         /// <summary> | ||||
|         /// Obtiene el último parte completo del mercado agroganadero. | ||||
|         /// </summary> | ||||
|         /// <returns>Una colección de objetos CotizacionGanado.</returns> | ||||
|         [HttpGet("agroganadero")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
| @@ -44,7 +61,10 @@ namespace Mercados.Api.Controllers | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // --- Endpoint para Granos --- | ||||
|         /// <summary> | ||||
|         /// Obtiene las últimas cotizaciones para los principales granos. | ||||
|         /// </summary> | ||||
|         /// <returns>Una colección de objetos CotizacionGrano.</returns> | ||||
|         [HttpGet("granos")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
| @@ -63,6 +83,10 @@ namespace Mercados.Api.Controllers | ||||
|         } | ||||
|  | ||||
|         // --- 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")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
| @@ -80,6 +104,10 @@ 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")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
| @@ -97,23 +125,37 @@ namespace Mercados.Api.Controllers | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         [HttpGet("bolsa/history/{ticker}")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
|         public async Task<IActionResult> GetBolsaHistory(string ticker, [FromQuery] string mercado = "Local", [FromQuery] int dias = 30) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 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 un ticker específico en un mercado determinado. | ||||
|         /// </summary> | ||||
|         /// <param name="ticker">El identificador del ticker.</param> | ||||
|         /// <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> | ||||
|         /// <returns>Una colección de objetos CotizacionBolsa.</returns> | ||||
|                 [HttpGet("bolsa/history/{ticker}")] | ||||
|                 [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||
|                 [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
|                 public async Task<IActionResult> GetBolsaHistory(string ticker, [FromQuery] string mercado = "Local", [FromQuery] int dias = 30) | ||||
|                 { | ||||
|                     try | ||||
|                     { | ||||
|                         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")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
| @@ -131,6 +173,12 @@ 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}")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
| @@ -147,5 +195,35 @@ namespace Mercados.Api.Controllers | ||||
|                 return StatusCode(500, "Ocurrió un error interno en el servidor."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /// <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}")] | ||||
|         [ProducesResponseType(typeof(bool), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
|         public async Task<IActionResult> IsMarketHoliday(string mercado) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 // Usamos la fecha actual en la zona horaria de Argentina | ||||
|                 TimeZoneInfo argentinaTimeZone; | ||||
|                 try { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); } | ||||
|                 catch { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); } | ||||
|  | ||||
|                 var todayInArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, argentinaTimeZone); | ||||
|  | ||||
|                 var esFeriado = await _holidayService.IsMarketHolidayAsync(mercado.ToUpper(), todayInArgentina); | ||||
|                 return Ok(esFeriado); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al comprobar si es feriado para el mercado {Mercado}.", mercado); | ||||
|                 // Si hay un error, devolvemos 'false' para no bloquear la UI innecesariamente. | ||||
|                 return Ok(false); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										27
									
								
								src/Mercados.Api/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/Mercados.Api/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| # --- Etapa 1: Build --- | ||||
| # Usamos la imagen del SDK de .NET 8 para compilar la aplicación | ||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||
| WORKDIR /src | ||||
|  | ||||
| # Copiamos los archivos .csproj de cada proyecto para restaurar las dependencias de forma eficiente | ||||
| COPY ["src/Mercados.Api/Mercados.Api.csproj", "Mercados.Api/"] | ||||
| COPY ["src/Mercados.Infrastructure/Mercados.Infrastructure.csproj", "Mercados.Infrastructure/"] | ||||
| COPY ["src/Mercados.Core/Mercados.Core.csproj", "Mercados.Core/"] | ||||
| COPY ["src/Mercados.Database/Mercados.Database.csproj", "Mercados.Database/"] | ||||
| RUN dotnet restore "Mercados.Api/Mercados.Api.csproj" | ||||
|  | ||||
| # Copiamos el resto del código fuente | ||||
| COPY src/. . | ||||
|  | ||||
| # Publicamos la aplicación en modo Release, optimizada para producción | ||||
| WORKDIR "/src/Mercados.Api" | ||||
| RUN dotnet publish "Mercados.Api.csproj" -c Release -o /app/publish | ||||
|  | ||||
| # --- Etapa 2: Final --- | ||||
| # Usamos la imagen de runtime de ASP.NET, que es mucho más ligera que la del SDK | ||||
| FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final | ||||
| WORKDIR /app | ||||
| COPY --from=build /app/publish . | ||||
|  | ||||
| # Definimos el punto de entrada para ejecutar la aplicación cuando el contenedor arranque | ||||
| ENTRYPOINT ["dotnet", "Mercados.Api.dll"] | ||||
| @@ -1,13 +1,14 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net9.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <UserSecretsId>28c6a673-1f1e-4140-aa75-a0d894d1fbc4</UserSecretsId> | ||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> | ||||
|     <PackageReference Include="FluentMigrator.Runner" Version="7.1.0" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" /> | ||||
|     <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" /> | ||||
|   | ||||
| @@ -3,10 +3,9 @@ using Mercados.Database.Migrations; | ||||
| using Mercados.Infrastructure; | ||||
| using Mercados.Infrastructure.Persistence; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using System.Reflection; | ||||
|  | ||||
| // Carga las variables de entorno desde el archivo .env en la raíz de la solución. | ||||
| DotNetEnv.Env.Load(); | ||||
| using Mercados.Api.Utils; | ||||
| using Microsoft.AspNetCore.HttpOverrides; | ||||
| using Mercados.Infrastructure.Services; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| @@ -19,55 +18,68 @@ builder.Services.AddCors(options => | ||||
|     options.AddPolicy(name: MyAllowSpecificOrigins, | ||||
|                       policy => | ||||
|                       { | ||||
|                           policy.WithOrigins("http://localhost:5173", // Desarrollo Frontend | ||||
|                                 "http://192.168.10.78:5173", // Desarrollo en Red Local | ||||
|                                 "https://www.eldia.com" // <--- DOMINIO DE PRODUCCIÓN | ||||
|                                 ) | ||||
|                           policy.WithOrigins("http://localhost:5173", | ||||
|                                              "http://192.168.10.78:5173", | ||||
|                                              "https://www.eldia.com", | ||||
|                                              "https://extras.eldia.com") | ||||
|                                 .AllowAnyHeader() | ||||
|                                 .AllowAnyMethod(); | ||||
|                       }); | ||||
| }); | ||||
|  | ||||
| // 1. Registramos nuestra fábrica de conexiones a la BD. | ||||
| // Registros de servicios (esto está perfecto) | ||||
| builder.Services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); | ||||
|  | ||||
| // 2. AÑADIR: Registramos los repositorios que la API necesitará para LEER datos. | ||||
| builder.Services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||
| builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||
| builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||
| builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||
| builder.Services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>(); | ||||
| builder.Services.AddMemoryCache(); | ||||
| builder.Services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>(); | ||||
| builder.Services.AddScoped<IHolidayService, FinnhubHolidayService>(); | ||||
|  | ||||
| // 3. Configurar FluentMigrator | ||||
| // Configuración de FluentMigrator (perfecto) | ||||
| builder.Services | ||||
|     .AddFluentMigratorCore() | ||||
|     .ConfigureRunner(rb => rb | ||||
|         // Usar el conector para SQL Server | ||||
|         .AddSqlServer() | ||||
|         // Obtener la cadena de conexión desde appsettings.json | ||||
|         .WithGlobalConnectionString(builder.Configuration.GetConnectionString("DefaultConnection")) | ||||
|         // Definir el ensamblado (proyecto) que contiene las migraciones | ||||
|         .ScanIn(typeof(CreateInitialTables).Assembly).For.Migrations()) | ||||
|     // Habilitar el logging para ver qué hacen las migraciones en la consola | ||||
|     .AddLogging(lb => lb.AddFluentMigratorConsole()); | ||||
|  | ||||
|  | ||||
| // Add services to the container. | ||||
| builder.Services.AddControllers(); | ||||
| builder.Services.AddControllers() | ||||
|     .AddJsonOptions(options => | ||||
|     { | ||||
|         // Añadimos nuestro convertidor personalizado para manejar las fechas. | ||||
|         options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter()); | ||||
|     }); | ||||
|  | ||||
| builder.Services.AddEndpointsApiExplorer(); | ||||
| builder.Services.AddSwaggerGen(); | ||||
|  | ||||
| builder.Services.Configure<ForwardedHeadersOptions>(options => | ||||
| { | ||||
|     options.ForwardedHeaders = | ||||
|         ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; | ||||
|     // En un entorno de producción real, deberías limitar esto a las IPs de tus proxies. | ||||
|     // options.KnownProxies.Add(IPAddress.Parse("192.168.5.X")); // IP de tu NPM | ||||
| }); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| // 4. Ejecutar las migraciones al iniciar la aplicación (ideal para desarrollo y despliegues sencillos) | ||||
| // Obtenemos el "scope" de los servicios para poder solicitar el MigrationRunner | ||||
| // Le decimos a la aplicación que USE el middleware de cabeceras de reenvío. | ||||
| // ¡El orden importa! Debe ir antes de UseHttpsRedirection y UseCors. | ||||
| app.UseForwardedHeaders(); | ||||
|  | ||||
| // Ejecución de migraciones (perfecto) | ||||
| using (var scope = app.Services.CreateScope()) | ||||
| { | ||||
|     var migrationRunner = scope.ServiceProvider.GetRequiredService<IMigrationRunner>(); | ||||
|     // Ejecuta las migraciones pendientes | ||||
|     migrationRunner.MigrateUp(); | ||||
| } | ||||
|  | ||||
| // Configure the HTTP request pipeline. | ||||
| // Pipeline de HTTP (perfecto) | ||||
| if (app.Environment.IsDevelopment()) | ||||
| { | ||||
|     app.UseSwagger(); | ||||
| @@ -75,11 +87,7 @@ if (app.Environment.IsDevelopment()) | ||||
| } | ||||
|  | ||||
| app.UseHttpsRedirection(); | ||||
|  | ||||
| app.UseCors(MyAllowSpecificOrigins); | ||||
|  | ||||
| app.UseAuthorization(); | ||||
|  | ||||
| app.MapControllers(); | ||||
|  | ||||
| app.Run(); | ||||
							
								
								
									
										40
									
								
								src/Mercados.Api/Utils/UtcDateTimeConverter.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/Mercados.Api/Utils/UtcDateTimeConverter.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace Mercados.Api.Utils | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Un convertidor de JSON personalizado para asegurar que los objetos DateTime | ||||
|     /// se serialicen al formato ISO 8601 en UTC (con el designador 'Z'). | ||||
|     /// </summary> | ||||
|     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) | ||||
|         { | ||||
|             // Al leer un string de fecha, nos aseguramos de que se interprete como UTC | ||||
|             return reader.GetDateTime().ToUniversalTime(); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Escribe un valor DateTime en formato UTC como una cadena en el escritor JSON. | ||||
|         /// </summary> | ||||
|         /// <param name="writer">El escritor JSON.</param> | ||||
|         /// <param name="value">El valor DateTime a escribir.</param> | ||||
|         /// <param name="options">Las opciones de serialización JSON.</param> | ||||
|                 public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) | ||||
|                 { | ||||
|                     // 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); | ||||
|                 } | ||||
|     } | ||||
| } | ||||
| @@ -9,11 +9,24 @@ | ||||
|   "ConnectionStrings": { | ||||
|     "DefaultConnection": "" | ||||
|   }, | ||||
|   "Schedules": { | ||||
|     "MercadoAgroganadero": "0 11 * * 1-5", | ||||
|     "BCR": "30 11 * * 1-5", | ||||
|     "Bolsas": "10 11-17 * * 1-5" | ||||
|   }, | ||||
|   "ApiKeys": { | ||||
|     "Finnhub": "", | ||||
|     "Bcr": { | ||||
|       "Key": "", | ||||
|       "Secret": "" | ||||
|     } | ||||
|   }, | ||||
|   "SmtpSettings": { | ||||
|     "Host": "", | ||||
|     "Port": 587, | ||||
|     "User": "", | ||||
|     "Pass": "", | ||||
|     "SenderName": "Servicio de Mercados", | ||||
|     "Recipient": "" | ||||
|   } | ||||
| } | ||||
| @@ -1,15 +1,53 @@ | ||||
| namespace Mercados.Core.Entities | ||||
| { | ||||
|   public class CotizacionBolsa | ||||
|   { | ||||
|     public long Id { get; set; } | ||||
|     public string Ticker { get; set; } = string.Empty; // "AAPL", "GGAL.BA", etc. | ||||
|     public string? NombreEmpresa { get; set; } | ||||
|     public string Mercado { get; set; } = string.Empty; // "EEUU" o "Local" | ||||
|     public decimal PrecioActual { get; set; } | ||||
|     public decimal Apertura { get; set; } | ||||
|     public decimal CierreAnterior { get; set; } | ||||
|     public decimal PorcentajeCambio { get; set; } | ||||
|     public DateTime FechaRegistro { get; set; } | ||||
|   } | ||||
|     /// <summary> | ||||
|     /// Representa una única captura de cotización para un activo de la bolsa de valores. | ||||
|     /// </summary> | ||||
|     public class CotizacionBolsa | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Identificador único del registro en la base de datos. | ||||
|         /// </summary> | ||||
|         public long Id { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 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,18 +1,68 @@ | ||||
| namespace Mercados.Core.Entities | ||||
| { | ||||
|   public class CotizacionGanado | ||||
|   { | ||||
|     public long Id { get; set; } | ||||
|     public string Categoria { get; set; } = string.Empty; | ||||
|     public string Especificaciones { get; set; } = string.Empty; | ||||
|     public decimal Maximo { get; set; } | ||||
|     public decimal Minimo { get; set; } | ||||
|     public decimal Promedio { get; set; } | ||||
|     public decimal Mediano { get; set; } | ||||
|     public int Cabezas { get; set; } | ||||
|     public int KilosTotales { get; set; } | ||||
|     public int KilosPorCabeza { get; set; } | ||||
|     public decimal ImporteTotal { get; set; } | ||||
|     public DateTime FechaRegistro { get; set; } | ||||
|   } | ||||
|     /// <summary> | ||||
|     /// Representa una cotización para una categoría de ganado en el Mercado Agroganadero. | ||||
|     /// </summary> | ||||
|     public class CotizacionGanado | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Identificador único del registro en la base de datos. | ||||
|         /// </summary> | ||||
|         public long Id { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// La categoría principal del ganado (ej. "NOVILLOS", "VACAS"). | ||||
|         /// </summary> | ||||
|         public string Categoria { get; set; } = string.Empty; | ||||
|  | ||||
|         /// <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,12 +1,38 @@ | ||||
| namespace Mercados.Core.Entities | ||||
| { | ||||
|   public class CotizacionGrano | ||||
|   { | ||||
|     public long Id { get; set; } | ||||
|     public string Nombre { get; set; } = string.Empty; // "Soja", "Trigo", etc. | ||||
|     public decimal Precio { get; set; } | ||||
|     public decimal VariacionPrecio { get; set; } | ||||
|     public DateTime FechaOperacion { get; set; } | ||||
|     public DateTime FechaRegistro { get; set; } | ||||
|   } | ||||
|     /// <summary> | ||||
|     /// Representa una cotización para un tipo de grano específico. | ||||
|     /// </summary> | ||||
|     public class CotizacionGrano | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Identificador único del registro en la base de datos. | ||||
|         /// </summary> | ||||
|         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,10 +1,31 @@ | ||||
| namespace Mercados.Core.Entities | ||||
| { | ||||
|   public class FuenteDato | ||||
|   { | ||||
|     public long Id { get; set; } | ||||
|     public string Nombre { get; set; } = string.Empty; // "BCR", "Finnhub", "YahooFinance", "MercadoAgroganadero" | ||||
|     public DateTime UltimaEjecucionExitosa { get; set; } | ||||
|     public string? Url { get; set; } | ||||
|   } | ||||
|     /// <summary> | ||||
|     /// 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. | ||||
|     /// </summary> | ||||
|     public class FuenteDato | ||||
|     { | ||||
|         /// <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; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										28
									
								
								src/Mercados.Core/Entities/MercadoFeriado.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/Mercados.Core/Entities/MercadoFeriado.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| namespace Mercados.Core.Entities | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Representa un único día feriado para un mercado bursátil específico. | ||||
|     /// </summary> | ||||
|     public class MercadoFeriado | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Identificador único del registro en la base de datos. | ||||
|         /// </summary> | ||||
|         public long Id { get; set; } | ||||
|  | ||||
|         /// <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; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// El nombre o la descripción del feriado (si está disponible). | ||||
|         /// </summary> | ||||
|         public string? Nombre { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -4,6 +4,7 @@ | ||||
|     <TargetFramework>net9.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||||
|   </PropertyGroup> | ||||
|  | ||||
| </Project> | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
|     <TargetFramework>net9.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|   | ||||
| @@ -1,19 +0,0 @@ | ||||
| using FluentMigrator; | ||||
|  | ||||
| namespace Mercados.Database.Migrations | ||||
| { | ||||
|     [Migration(20240702133000)] | ||||
|     public class AddNameToStocks : Migration | ||||
|     { | ||||
|         public override void Up() | ||||
|         { | ||||
|             Alter.Table("CotizacionesBolsa") | ||||
|                 .AddColumn("NombreEmpresa").AsString(255).Nullable(); | ||||
|         } | ||||
|  | ||||
|         public override void Down() | ||||
|         { | ||||
|             Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -2,8 +2,10 @@ using FluentMigrator; | ||||
|  | ||||
| namespace Mercados.Database.Migrations | ||||
| { | ||||
|     // El número es la versión única de esta migración. | ||||
|     // Usar un timestamp es una práctica común y segura. | ||||
|     /// <summary> | ||||
|     /// Migración inicial que crea las tablas necesarias para almacenar | ||||
|     /// las cotizaciones de ganado, granos, bolsa y fuentes de datos. | ||||
|     /// </summary> | ||||
|     [Migration(20250701113000)] | ||||
|     public class CreateInitialTables : Migration | ||||
|     { | ||||
|   | ||||
| @@ -0,0 +1,29 @@ | ||||
| using FluentMigrator; | ||||
|  | ||||
| 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)] | ||||
|     public class AddNameToStocks : Migration | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Aplica la migración, añadiendo la columna 'NombreEmpresa'. | ||||
|         /// </summary> | ||||
|         public override void Up() | ||||
|         { | ||||
|             Alter.Table("CotizacionesBolsa") | ||||
|                 .AddColumn("NombreEmpresa").AsString(255).Nullable(); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Revierte la migración, eliminando la columna 'NombreEmpresa'. | ||||
|         /// </summary> | ||||
|         public override void Down() | ||||
|         { | ||||
|             Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| using FluentMigrator; | ||||
|  | ||||
| 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)] | ||||
|     public class CreateMercadoFeriadoTable : Migration | ||||
|     { | ||||
|         private const string TableName = "MercadosFeriados"; | ||||
|          | ||||
|         /// <summary> | ||||
|         /// Define la estructura de la tabla 'MercadosFeriados' y crea un índice único. | ||||
|         /// </summary> | ||||
|         public override void Up() | ||||
|         { | ||||
|             Create.Table(TableName) | ||||
|                 .WithColumn("Id").AsInt64().PrimaryKey().Identity() | ||||
|                 .WithColumn("CodigoMercado").AsString(10).NotNullable() | ||||
|                 .WithColumn("Fecha").AsDate().NotNullable() // Usamos AsDate() para guardar solo la fecha | ||||
|                 .WithColumn("Nombre").AsString(255).Nullable(); | ||||
|  | ||||
|             // Creamos un índice para buscar rápidamente por mercado y fecha | ||||
|             Create.Index($"IX_{TableName}_CodigoMercado_Fecha") | ||||
|                 .OnTable(TableName) | ||||
|                 .OnColumn("CodigoMercado").Ascending() | ||||
|                 .OnColumn("Fecha").Ascending() | ||||
|                 .WithOptions().Unique(); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Revierte la migración eliminando la tabla 'MercadosFeriados'. | ||||
|         /// </summary> | ||||
|         public override void Down() | ||||
|         { | ||||
|             Delete.Table(TableName); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -8,37 +8,83 @@ using System.Text.Json.Serialization; | ||||
|  | ||||
| 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 | ||||
|     { | ||||
|         #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 | ||||
|         { | ||||
|             /// <summary> | ||||
|             /// Contenedor de datos del token. | ||||
|             /// </summary> | ||||
|             [JsonPropertyName("data")] | ||||
|             public TokenData? Data { get; set; } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Contiene el token de autenticación. | ||||
|         /// </summary> | ||||
|         private class TokenData | ||||
|         { | ||||
|             /// <summary> | ||||
|             /// El token JWT para autenticar las solicitudes. | ||||
|             /// </summary> | ||||
|             [JsonPropertyName("token")] | ||||
|             public string? Token { get; set; } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// DTO para la respuesta del endpoint de precios de BCR. | ||||
|         /// </summary> | ||||
|         private class BcrPreciosResponse | ||||
|         { | ||||
|             /// <summary> | ||||
|             /// Lista de precios de granos. | ||||
|             /// </summary> | ||||
|             [JsonPropertyName("data")] | ||||
|             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 | ||||
|         { | ||||
|             /// <summary> | ||||
|             /// El precio de cotización del grano. | ||||
|             /// </summary> | ||||
|             [JsonPropertyName("precio_Cotizacion")] | ||||
|             public decimal PrecioCotizacion { get; set; } | ||||
|             /// <summary> | ||||
|             /// La variación del precio respecto a la cotización anterior. | ||||
|             /// </summary> | ||||
|             [JsonPropertyName("variacion_Precio_Cotizacion")] | ||||
|             public decimal VariacionPrecioCotizacion { get; set; } | ||||
|             /// <summary> | ||||
|             /// La fecha en que se realizó la operación. | ||||
|             /// </summary> | ||||
|             [JsonPropertyName("fecha_Operacion_Pizarra")] | ||||
|             public DateTime FechaOperacionPizarra { get; set; } | ||||
|         } | ||||
|         #endregion | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         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"; | ||||
|          | ||||
|         /// <summary> | ||||
|         /// Mapeo de nombres de granos a sus IDs correspondientes en la API de BCR. | ||||
|         /// </summary> | ||||
|         private readonly Dictionary<string, int> _grainIds = new() | ||||
|         { | ||||
|             { "Trigo", 1 }, { "Maiz", 2 }, { "Sorgo", 3 }, { "Girasol", 20 }, { "Soja", 21 } | ||||
| @@ -50,6 +96,14 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|         private readonly IConfiguration _configuration; | ||||
|         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( | ||||
|             IHttpClientFactory httpClientFactory, | ||||
|             ICotizacionGranoRepository cotizacionRepository, | ||||
| @@ -64,6 +118,7 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||
| @@ -105,7 +160,11 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de granos de BCR."); | ||||
|                 if (!cotizaciones.Any()) | ||||
|                 { | ||||
|                     _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se encontraron datos de granos.", SourceName); | ||||
|                     return (true, "Conexión exitosa, pero no se encontraron nuevos datos de granos."); | ||||
|                 } | ||||
|  | ||||
|                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||
|                 await UpdateSourceInfoAsync(); | ||||
| @@ -120,11 +179,16 @@ 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) | ||||
|         { | ||||
|             var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); | ||||
|             request.Headers.Add("api_key", Environment.GetEnvironmentVariable("BCR_API_KEY")); | ||||
|             request.Headers.Add("secret", Environment.GetEnvironmentVariable("BCR_API_SECRET")); | ||||
|             request.Headers.Add("api_key", _configuration["ApiKeys:Bcr:Key"]); | ||||
|             request.Headers.Add("secret", _configuration["ApiKeys:Bcr:Secret"]); | ||||
|  | ||||
|             var response = await client.SendAsync(request); | ||||
|             response.EnsureSuccessStatusCode(); | ||||
| @@ -133,6 +197,9 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             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() | ||||
|         { | ||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||
|   | ||||
| @@ -7,8 +7,18 @@ using System.Net.Http; | ||||
|  | ||||
| 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 | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Nombre de la fuente de datos utilizada por este fetcher. | ||||
|         /// </summary> | ||||
|         public string SourceName => "Finnhub"; | ||||
|         private readonly List<string> _tickers = new() { | ||||
|             // Tecnológicas y ETFs | ||||
| @@ -16,7 +26,7 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             // Empresas 'Latinas' en Wall Street | ||||
|             "MELI", "GLOB", | ||||
|             // ADRs Argentinos | ||||
|             "YPF", "GGAL", "BMA", "LOMA", "PAM", "TEO", "TGS", "EDN", "CRESY", "CEPU", "BBAR" | ||||
|             "YPF", "GGAL", "BMA", "LOMA", "TEO", "TGS", "EDN", "BBAR" | ||||
|         }; | ||||
|  | ||||
|         private readonly FinnhubClient _client; | ||||
| @@ -24,6 +34,17 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||
|         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( | ||||
|             IConfiguration configuration, | ||||
|             IHttpClientFactory httpClientFactory, | ||||
| @@ -31,18 +52,23 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             IFuenteDatoRepository fuenteDatoRepository, | ||||
|             ILogger<FinnhubDataFetcher> logger) | ||||
|         { | ||||
|             var apiKey = Environment.GetEnvironmentVariable("FINNHUB_API_KEY"); | ||||
|             var apiKey = configuration["ApiKeys:Finnhub"]; | ||||
|             if (string.IsNullOrEmpty(apiKey)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("La clave de API de Finnhub no está configurada en appsettings.json (ApiKeys:Finnhub)"); | ||||
|                 throw new InvalidOperationException("La clave de API de Finnhub no está configurada (ApiKeys:Finnhub)"); | ||||
|             } | ||||
|             // Le pasamos el cliente HTTP que ya está configurado con Polly en Program.cs | ||||
|             _client = new FinnhubClient(httpClientFactory.CreateClient("FinnhubDataFetcher"), apiKey); | ||||
|             _cotizacionRepository = cotizacionRepository; | ||||
|             _fuenteDatoRepository = fuenteDatoRepository; | ||||
|             _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() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||
| @@ -76,7 +102,11 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de Finnhub."); | ||||
|             if (!cotizaciones.Any()) | ||||
|             { | ||||
|                 _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se obtuvieron cotizaciones de los tickers solicitados.", SourceName); | ||||
|                 return (true, "Conexión exitosa, pero no se encontraron cotizaciones."); | ||||
|             } | ||||
|  | ||||
|             await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||
|             await UpdateSourceInfoAsync(); | ||||
| @@ -85,6 +115,9 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             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() | ||||
|         { | ||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||
|   | ||||
							
								
								
									
										138
									
								
								src/Mercados.Infrastructure/DataFetchers/HolidayDataFetcher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/Mercados.Infrastructure/DataFetchers/HolidayDataFetcher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System.Net.Http.Json; | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace Mercados.Infrastructure.DataFetchers | ||||
| { | ||||
|     /// <summary> | ||||
|     /// DTO para deserializar la respuesta de la API de Finnhub al obtener feriados de mercado. | ||||
|     /// </summary> | ||||
|     public class MarketHolidayResponse | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Lista de feriados del mercado. | ||||
|         /// </summary> | ||||
|         [JsonPropertyName("data")] | ||||
|         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 | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Fecha del feriado en formato de cadena (YYYY-MM-DD). | ||||
|         /// </summary> | ||||
|         [JsonPropertyName("at")] | ||||
|         public string? At { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Fecha del feriado como <see cref="DateOnly"/>. | ||||
|         /// </summary> | ||||
|         [JsonIgnore] | ||||
|         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 | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         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 IHttpClientFactory _httpClientFactory; | ||||
|         private readonly IMercadoFeriadoRepository _feriadoRepository; | ||||
|         private readonly IConfiguration _configuration; | ||||
|         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( | ||||
|             IHttpClientFactory httpClientFactory, | ||||
|             IMercadoFeriadoRepository feriadoRepository, | ||||
|             IConfiguration configuration, | ||||
|             ILogger<HolidayDataFetcher> logger) | ||||
|         { | ||||
|             _httpClientFactory = httpClientFactory; | ||||
|             _feriadoRepository = feriadoRepository; | ||||
|             _configuration = configuration; | ||||
|             _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() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando actualización de feriados."); | ||||
|  | ||||
|             // Verificamos que la API Key de Finnhub esté configurada | ||||
|             var apiKey = _configuration["ApiKeys:Finnhub"]; | ||||
|             if (string.IsNullOrEmpty(apiKey)) | ||||
|             { | ||||
|                 return (false, "API Key de Finnhub no configurada."); | ||||
|             } | ||||
|  | ||||
|             var client = _httpClientFactory.CreateClient("FinnhubDataFetcher"); | ||||
|             // Iteramos sobre cada código de mercado configurado | ||||
|             foreach (var marketCode in _marketCodes) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}"; | ||||
|                     // Ahora la deserialización funcionará porque la clase existe | ||||
|                     var response = await client.GetFromJsonAsync<MarketHolidayResponse>(apiUrl); | ||||
|                      | ||||
|                     // Si obtuvimos datos en la respuesta | ||||
|                     if (response?.Data != null) | ||||
|                     { | ||||
|                         // Convertimos los datos de la API al formato de nuestra entidad MercadoFeriado | ||||
|                         var nuevosFeriados = response.Data.Select(h => new MercadoFeriado | ||||
|                         { | ||||
|                             CodigoMercado = marketCode, | ||||
|                             Fecha = h.Date.ToDateTime(TimeOnly.MinValue), | ||||
|                             Nombre = "Feriado Bursátil" | ||||
|                         }).ToList(); | ||||
|                          | ||||
|                         // Guardamos los feriados en la base de datos, reemplazando los existentes para ese mercado | ||||
|                         await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados); | ||||
|                         _logger.LogInformation( | ||||
|                             "Feriados para {MarketCode} actualizados exitosamente: {Count} registros.",  | ||||
|                             marketCode,  | ||||
|                             nuevosFeriados.Count); | ||||
|                     } | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _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."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -6,9 +6,21 @@ using System.Globalization; | ||||
|  | ||||
| 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 | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         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 readonly IHttpClientFactory _httpClientFactory; | ||||
| @@ -16,6 +28,17 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||
|         private readonly ILogger<MercadoAgroFetcher> _logger; | ||||
|  | ||||
|         /// <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( | ||||
|             IHttpClientFactory httpClientFactory, | ||||
|             ICotizacionGanadoRepository cotizacionRepository, | ||||
| @@ -28,6 +51,12 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         /// <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() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||
| @@ -43,7 +72,9 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|                 var cotizaciones = ParseHtmlToEntities(htmlContent); | ||||
|                 if (!cotizaciones.Any()) | ||||
|                 { | ||||
|                     return (false, "No se encontraron cotizaciones válidas en el HTML."); | ||||
|                     // Esto NO es un error crítico, es un estado informativo. | ||||
|                     _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."); | ||||
|                 } | ||||
|  | ||||
|                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||
| @@ -54,26 +85,40 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             } | ||||
|             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); | ||||
|                 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() | ||||
|         { | ||||
|             // Pedimos el cliente HTTP con el nombre específico que tiene la política de Polly | ||||
|             var client = _httpClientFactory.CreateClient("MercadoAgroFetcher"); | ||||
|  | ||||
|             // 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"); | ||||
|  | ||||
|             var response = await client.GetAsync(DataUrl); | ||||
|             response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|             // El sitio usa una codificación específica, hay que decodificarla correctamente. | ||||
|             var stream = await response.Content.ReadAsStreamAsync(); | ||||
|             using var reader = new StreamReader(stream, System.Text.Encoding.GetEncoding("windows-1252")); | ||||
|             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) | ||||
|         { | ||||
|             var config = Configuration.Default; | ||||
| @@ -104,7 +149,7 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|                         Categoria = celdas[1], | ||||
|                         Especificaciones = $"{celdas[2]} - {celdas[3]}", | ||||
|                         Maximo = ParseDecimal(celdas[4]), | ||||
|                         Minimo = ParseDecimal(celdas[5]), | ||||
|                          Minimo = ParseDecimal(celdas[5]), | ||||
|                         Promedio = ParseDecimal(celdas[6]), | ||||
|                         Mediano = ParseDecimal(celdas[7]), | ||||
|                         Cabezas = ParseInt(celdas[8]), | ||||
| @@ -117,21 +162,24 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}", string.Join(" | ", celdas)); | ||||
|                     _logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}", | ||||
|                         string.Join(" | ", celdas)); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             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() | ||||
|         { | ||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||
|             if (fuente == null) | ||||
|             { | ||||
|                 await _fuenteDatoRepository.CrearAsync(new FuenteDato | ||||
|                 { | ||||
|                     Nombre = SourceName, | ||||
|                  await _fuenteDatoRepository.CrearAsync(new FuenteDato | ||||
|                  { | ||||
|                      Nombre = SourceName, | ||||
|                     Url = DataUrl, | ||||
|                     UltimaEjecucionExitosa = DateTime.UtcNow | ||||
|                 }); | ||||
| @@ -145,17 +193,33 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|         } | ||||
|  | ||||
|         // --- 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"); | ||||
|  | ||||
|         /// <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) | ||||
|         { | ||||
|             // El sitio usa '.' como separador de miles y ',' como decimal. | ||||
|             // Primero quitamos el de miles, luego reemplazamos la coma decimal por un punto. | ||||
|             var cleanValue = value.Replace("$", "").Replace(".", "").Trim(); | ||||
|             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) | ||||
|         { | ||||
|             return int.Parse(value.Replace(".", ""), CultureInfo.InvariantCulture); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|          { | ||||
|              return int.Parse(value.Replace(".", ""), CultureInfo.InvariantCulture); | ||||
|          } | ||||
|      } | ||||
|  } | ||||
| @@ -1,13 +1,19 @@ | ||||
| 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 | ||||
|     { | ||||
|         /// <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) | ||||
|         { | ||||
|           // USA | ||||
|             { "SPY", "S&P 500 ETF" }, // Cambiado de GSPC a SPY para Finnhub | ||||
|             { "SPY", "S&P 500 ETF" }, | ||||
|             { "AAPL", "Apple Inc." }, | ||||
|             { "MSFT", "Microsoft Corp." }, | ||||
|             { "AMZN", "Amazon.com, Inc." }, | ||||
|             { "NVDA", "NVIDIA Corp." }, | ||||
|             { "AMD", "Advanced Micro Devices" }, | ||||
| @@ -19,6 +25,7 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             { "XLE", "Energy Select Sector SPDR" }, | ||||
|             { "XLK", "Technology Select Sector SPDR" }, | ||||
|             { "MELI", "MercadoLibre, Inc." }, | ||||
|             { "MSFT", "Microsoft Corp." }, | ||||
|             { "GLOB", "Globant" }, | ||||
|              | ||||
|             // ADRs Argentinos que cotizan en EEUU | ||||
| @@ -53,9 +60,15 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|             { "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) | ||||
|         { | ||||
|             return Names.GetValueOrDefault(ticker); | ||||
|             // Devuelve el nombre si existe, o null si no se encuentra la clave. | ||||
|             return Names.TryGetValue(ticker, out var name) ? name : $"Ticker no reconocido: {ticker}"; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -5,30 +5,61 @@ using YahooFinanceApi; | ||||
|  | ||||
| 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 | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         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() { | ||||
|             "^GSPC", // Índice S&P 500 | ||||
|             "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA",  | ||||
|             "TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA",  | ||||
|             "^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", "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 IFuenteDatoRepository _fuenteDatoRepository; | ||||
|         private readonly ILogger<YahooFinanceDataFetcher> _logger; | ||||
|  | ||||
|         public YahooFinanceDataFetcher( | ||||
|             ICotizacionBolsaRepository cotizacionRepository,  | ||||
|             IFuenteDatoRepository fuenteDatoRepository,  | ||||
|             ILogger<YahooFinanceDataFetcher> logger) | ||||
|         { | ||||
|             _cotizacionRepository = cotizacionRepository; | ||||
|             _fuenteDatoRepository = fuenteDatoRepository; | ||||
|             _logger = logger; | ||||
|         } | ||||
|         /// <summary> | ||||
|         /// Inicializa una nueva instancia de la clase <see cref="YahooFinanceDataFetcher"/>. | ||||
|         /// </summary> | ||||
|         /// <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 (Yahoo Finance).</param> | ||||
|         /// <param name="logger">Logger para registrar información y errores durante la ejecución.</param> | ||||
|                 public YahooFinanceDataFetcher( | ||||
|                     ICotizacionBolsaRepository cotizacionRepository, | ||||
|                     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() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||
| @@ -40,8 +71,8 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|                 foreach (var sec in securities.Values) | ||||
|                 { | ||||
|                     if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; | ||||
|                      | ||||
|                     string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU"; | ||||
|  | ||||
|                     string mercado = DetermineMarket(sec.Symbol); | ||||
|  | ||||
|                     cotizaciones.Add(new CotizacionBolsa | ||||
|                     { | ||||
| @@ -56,7 +87,11 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|                     }); | ||||
|                 } | ||||
|  | ||||
|                 if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de Yahoo Finance."); | ||||
|                 if (!cotizaciones.Any()) | ||||
|                 { | ||||
|                     _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se obtuvieron cotizaciones de los tickers solicitados.", SourceName); | ||||
|                     return (true, "Conexión exitosa, pero no se encontraron cotizaciones."); | ||||
|                 } | ||||
|  | ||||
|                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||
|                 await UpdateSourceInfoAsync(); | ||||
| @@ -70,7 +105,28 @@ namespace Mercados.Infrastructure.DataFetchers | ||||
|                 return (false, $"Error: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|  | ||||
|         /// <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() | ||||
|         { | ||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||
|   | ||||
| @@ -7,7 +7,9 @@ | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="AngleSharp" Version="1.3.0" /> | ||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||
|     <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" /> | ||||
|     <PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.6" /> | ||||
| @@ -19,6 +21,7 @@ | ||||
|     <TargetFramework>net9.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||||
|   </PropertyGroup> | ||||
|  | ||||
| </Project> | ||||
|   | ||||
| @@ -2,8 +2,15 @@ using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence | ||||
| { | ||||
|   public interface IDbConnectionFactory | ||||
|   { | ||||
|     IDbConnection CreateConnection(); | ||||
|   } | ||||
|     /// <summary> | ||||
|     /// Define una interfaz para una fábrica de conexiones a la base de datos. | ||||
|     /// </summary> | ||||
|     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,26 +4,34 @@ using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     /// <inheritdoc cref="ICotizacionBolsaRepository"/> | ||||
|     public class CotizacionBolsaRepository : ICotizacionBolsaRepository | ||||
|     { | ||||
|         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) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|  | ||||
|             const string sql = @" | ||||
|                 INSERT INTO CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)  | ||||
|                 VALUES (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; | ||||
|             const string sql = @"INSERT INTO  | ||||
|                     CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)  | ||||
|                 VALUES  | ||||
|                     (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; | ||||
|  | ||||
|             await connection.ExecuteAsync(sql, cotizaciones); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
| @@ -48,6 +56,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories | ||||
|             return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado }); | ||||
|         } | ||||
|          | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|   | ||||
| @@ -4,22 +4,29 @@ using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     /// <inheritdoc cref="ICotizacionGanadoRepository"/> | ||||
|     public class CotizacionGanadoRepository : ICotizacionGanadoRepository | ||||
|     { | ||||
|         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) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|  | ||||
|             // Dapper puede insertar una colección de objetos de una sola vez, ¡muy eficiente! | ||||
|             const string sql = @" | ||||
|                 INSERT INTO CotizacionesGanado ( | ||||
|                 INSERT INTO  | ||||
|                     CotizacionesGanado ( | ||||
|                     Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano,  | ||||
|                     Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro | ||||
|                 )  | ||||
| @@ -30,6 +37,8 @@ namespace Mercados.Infrastructure.Persistence.Repositories | ||||
|  | ||||
|             await connection.ExecuteAsync(sql, cotizaciones); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync() | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
| @@ -44,6 +53,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories | ||||
|             return await connection.QueryAsync<CotizacionGanado>(sql); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|   | ||||
| @@ -4,25 +4,34 @@ using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     /// <inheritdoc cref="ICotizacionGranoRepository"/> | ||||
|     public class CotizacionGranoRepository : ICotizacionGranoRepository | ||||
|     { | ||||
|         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) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|  | ||||
|             const string sql = @" | ||||
|                 INSERT INTO CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)  | ||||
|                 VALUES (@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);"; | ||||
|             const string sql = @"INSERT INTO  | ||||
|                     CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)  | ||||
|                 VALUES  | ||||
|                     (@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);"; | ||||
|  | ||||
|             await connection.ExecuteAsync(sql, cotizaciones); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync() | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
| @@ -45,6 +54,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories | ||||
|             return await connection.QueryAsync<CotizacionGrano>(sql); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|   | ||||
| @@ -4,37 +4,42 @@ using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     /// <inheritdoc cref="IFuenteDatoRepository"/> | ||||
|     public class FuenteDatoRepository : IFuenteDatoRepository | ||||
|     { | ||||
|         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) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|          | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<FuenteDato?> ObtenerPorNombreAsync(string nombre) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|             const string sql = "SELECT * FROM FuentesDatos WHERE Nombre = @Nombre;"; | ||||
|             return await connection.QuerySingleOrDefaultAsync<FuenteDato>(sql, new { Nombre = nombre }); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task CrearAsync(FuenteDato fuenteDato) | ||||
|         { | ||||
|             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);"; | ||||
|             await connection.ExecuteAsync(sql, fuenteDato); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task ActualizarAsync(FuenteDato fuenteDato) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|             const string sql = @" | ||||
|                 UPDATE FuentesDatos  | ||||
|                 SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url  | ||||
|             const string sql = @"UPDATE FuentesDatos  | ||||
|                 SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url | ||||
|                 WHERE Id = @Id;"; | ||||
|             await connection.ExecuteAsync(sql, fuenteDato); | ||||
|         } | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     // Esta interfaz no es estrictamente necesaria ahora, pero es útil para futuras abstracciones. | ||||
|     /// <summary> | ||||
|     /// 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 | ||||
|     { | ||||
|     } | ||||
|   | ||||
| @@ -2,10 +2,31 @@ using Mercados.Core.Entities; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Define el contrato para el repositorio que gestiona las cotizaciones de la bolsa. | ||||
|     /// </summary> | ||||
|     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); | ||||
|  | ||||
|         /// <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); | ||||
|  | ||||
|         /// <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); | ||||
|     } | ||||
| } | ||||
| @@ -2,10 +2,30 @@ using Mercados.Core.Entities; | ||||
|  | ||||
| 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 | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Guarda una colección de cotizaciones de ganado en la base de datos. | ||||
|         /// </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); | ||||
|     } | ||||
| } | ||||
| @@ -2,10 +2,29 @@ using Mercados.Core.Entities; | ||||
|  | ||||
| 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 | ||||
|     { | ||||
|         /// <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); | ||||
|  | ||||
|         /// <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(); | ||||
|  | ||||
|         /// <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); | ||||
|     } | ||||
| } | ||||
| @@ -2,10 +2,28 @@ using Mercados.Core.Entities; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Define el contrato para el repositorio que gestiona las fuentes de datos. | ||||
|     /// </summary> | ||||
|     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); | ||||
|  | ||||
|         /// <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); | ||||
|  | ||||
|         /// <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); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| using Mercados.Core.Entities; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Define el contrato para el repositorio que gestiona los feriados de los mercados. | ||||
|     /// </summary> | ||||
|     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); | ||||
|         /// <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); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,73 @@ | ||||
| using Dapper; | ||||
| using Mercados.Core.Entities; | ||||
| using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     /// <inheritdoc cref="IMercadoFeriadoRepository"/> | ||||
|     public class MercadoFeriadoRepository : IMercadoFeriadoRepository | ||||
|     { | ||||
|         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) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|             const string sql = @"SELECT *  | ||||
|                 FROM MercadosFeriados  | ||||
|                 WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; | ||||
|             return await connection.QueryAsync<MercadoFeriado>(sql, new  | ||||
|             {  | ||||
|                 CodigoMercado = codigoMercado,  | ||||
|                 Anio = anio  | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|             connection.Open(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 // Obtenemos el año del primer feriado (asumimos que todos son del mismo año) | ||||
|                 var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year; | ||||
|                 if (!anio.HasValue) return; // Si no hay feriados, no hay nada que hacer | ||||
|  | ||||
|                 // 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) | ||||
|                         VALUES (@CodigoMercado, @Fecha, @Nombre);"; | ||||
|                     await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction); | ||||
|                 } | ||||
|  | ||||
|                 // Si todo sale bien, confirmar la transacción | ||||
|                 transaction.Commit(); | ||||
|             } | ||||
|             catch | ||||
|             { | ||||
|                 // Si hay algún error, deshacer la transacción para no dejar datos inconsistentes | ||||
|                 transaction.Rollback(); | ||||
|  | ||||
|                 // Relanzar la excepción para que el llamador sepa que algo falló | ||||
|                 throw; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,25 +1,33 @@ | ||||
| using Mercados.Infrastructure.Persistence; | ||||
| using Microsoft.Data.SqlClient; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Configuration; // Asegúrate de que este using esté | ||||
| using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure | ||||
| { | ||||
|   /// <summary> | ||||
|   /// Proporciona una fábrica para crear conexiones a la base de datos SQL. | ||||
|   /// </summary> | ||||
|   public class SqlConnectionFactory : IDbConnectionFactory | ||||
|   { | ||||
|     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) | ||||
|     { | ||||
|       // Leemos directamente de la variable de entorno | ||||
|       _connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING") | ||||
|           ?? throw new ArgumentNullException(nameof(configuration), "La variable de entorno 'DB_CONNECTION_STRING' no fue encontrada."); | ||||
|       // Variable de entorno 'DB_CONNECTION_STRING' si está disponible, | ||||
|       // o el valor de appsettings.json si no lo está. | ||||
|       _connectionString = configuration.GetConnectionString("DefaultConnection") | ||||
|           ?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada."); | ||||
|     } | ||||
|  | ||||
|     public IDbConnection CreateConnection() | ||||
|         /// <inheritdoc /> | ||||
|         public IDbConnection CreateConnection() | ||||
|     { | ||||
|       // Dapper se encargará de abrir y cerrar la conexión automáticamente. | ||||
|       return new SqlConnection(_connectionString); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| } | ||||
| @@ -0,0 +1,98 @@ | ||||
| using MailKit.Net.Smtp; | ||||
| using MailKit.Security; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MimeKit; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Services | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Servicio que gestiona el envío de notificaciones por correo electrónico. | ||||
|     /// </summary> | ||||
|     public class EmailNotificationService : INotificationService | ||||
|     { | ||||
|         private readonly ILogger<EmailNotificationService> _logger; | ||||
|         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) | ||||
|         { | ||||
|             _logger = logger; | ||||
|             _configuration = configuration; | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         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) | ||||
|             var smtpHost = _configuration["SmtpSettings:Host"]; | ||||
|             var smtpPort = _configuration.GetValue<int>("SmtpSettings:Port"); | ||||
|             var smtpUser = _configuration["SmtpSettings:User"]; | ||||
|             var smtpPass = _configuration["SmtpSettings:Pass"]; | ||||
|             var senderName = _configuration["SmtpSettings:SenderName"]; | ||||
|             var recipient = _configuration["SmtpSettings:Recipient"]; | ||||
|  | ||||
|             if (string.IsNullOrEmpty(smtpHost) || string.IsNullOrEmpty(smtpUser) || string.IsNullOrEmpty(smtpPass)) | ||||
|             { | ||||
|                 _logger.LogError("La configuración SMTP está incompleta. No se puede enviar el email de alerta."); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Usamos la hora actual en UTC para el evento. | ||||
|             var displayTime = DateTime.UtcNow; | ||||
|  | ||||
|             // Buscamos la zona horaria de Argentina | ||||
|             TimeZoneInfo argentinaTimeZone; | ||||
|             try | ||||
|             { | ||||
|                 argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); | ||||
|             } | ||||
|             catch (TimeZoneNotFoundException) | ||||
|             { | ||||
|                 argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); | ||||
|             } | ||||
|  | ||||
|             // Convertimos la hora UTC a la hora local de Argentina | ||||
|             var localTime = TimeZoneInfo.ConvertTimeFromUtc(displayTime, argentinaTimeZone); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var email = new MimeMessage(); | ||||
|                 email.From.Add(new MailboxAddress(senderName, smtpUser)); | ||||
|                 email.To.Add(MailboxAddress.Parse(recipient)); | ||||
|                 email.Subject = subject; | ||||
|  | ||||
|                 // Creamos un cuerpo de correo un poco más elaborado | ||||
|                 var builder = new BodyBuilder | ||||
|                 { | ||||
|                     HtmlBody = $@" | ||||
|                         <h1>Alerta del Servicio de Mercados</h1> | ||||
|                         <p>Se ha detectado un error crítico que requiere atención.</p> | ||||
|                         <hr> | ||||
|                         <h3>Detalles del Error:</h3> | ||||
|                         <p><strong>Mensaje:</strong> {message}</p> | ||||
|                         <p><strong>Hora del Evento (AR):</strong> {localTime:yyyy-MM-dd HH:mm:ss}</p>" | ||||
|                 }; | ||||
|                 email.Body = builder.ToMessageBody(); | ||||
|  | ||||
|                 using var smtp = new SmtpClient(); | ||||
|                 // Usamos SecureSocketOptions.StartTls que es el estándar moderno para el puerto 587. | ||||
|                 // Si tu servidor usa el puerto 465, deberías usar SecureSocketOptions.SslOnConnect. | ||||
|                 await smtp.ConnectAsync(smtpHost, smtpPort, SecureSocketOptions.StartTls); | ||||
|                 await smtp.AuthenticateAsync(smtpUser, smtpPass); | ||||
|                 await smtp.SendAsync(email); | ||||
|                 await smtp.DisconnectAsync(true); | ||||
|  | ||||
|                 _logger.LogInformation("Email de alerta enviado exitosamente a {Recipient}", recipient); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogCritical(ex, "FALLO EL ENVÍO DEL EMAIL DE ALERTA. Revisa la configuración SMTP y la conectividad."); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,65 @@ | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Services | ||||
| { | ||||
|     /// <summary> | ||||
|     /// 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 IMemoryCache _cache; | ||||
|         private readonly ILogger<FinnhubHolidayService> _logger; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Inicializa una nueva instancia de la clase <see cref="FinnhubHolidayService"/>. | ||||
|         /// </summary> | ||||
|         /// <param name="feriadoRepository">Repositorio para acceder a los feriados de mercado.</param> | ||||
|         /// <param name="cache">Caché en memoria para almacenar los feriados.</param> | ||||
|         /// <param name="logger">Logger para registrar información y errores.</param> | ||||
|                 public FinnhubHolidayService( | ||||
|                     IMercadoFeriadoRepository feriadoRepository, | ||||
|                     IMemoryCache cache, | ||||
|                     ILogger<FinnhubHolidayService> logger) | ||||
|                 { | ||||
|                     _feriadoRepository = feriadoRepository; | ||||
|                     _cache = cache; | ||||
|                     _logger = logger; | ||||
|                 } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Determina si una fecha específica es feriado de mercado para el código de mercado proporcionado. | ||||
|         /// </summary> | ||||
|         /// <param name="marketCode">Código del mercado a consultar.</param> | ||||
|         /// <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)) | ||||
|             { | ||||
|                 _logger.LogInformation("Caché de feriados no encontrada para {MarketCode}. Obteniendo desde la base de datos.", marketCode); | ||||
|                  | ||||
|                 try | ||||
|                 { | ||||
|                     // Llama a NUESTRA base de datos, no a la API externa. | ||||
|                     var feriadosDesdeDb = await _feriadoRepository.ObtenerPorMercadoYAnioAsync(marketCode, date.Year); | ||||
|                     holidays = feriadosDesdeDb.Select(h => DateOnly.FromDateTime(h.Fecha)).ToHashSet(); | ||||
|                     _cache.Set(cacheKey, holidays, TimeSpan.FromHours(24)); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogError(ex, "No se pudo obtener la lista de feriados para {MarketCode} desde la DB.", marketCode); | ||||
|                     return false; // Asumimos que no es feriado si la DB falla | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return holidays?.Contains(dateOnly) ?? false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								src/Mercados.Infrastructure/Services/IHolidayService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/Mercados.Infrastructure/Services/IHolidayService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| namespace Mercados.Infrastructure.Services | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Define un servicio para consultar si una fecha es feriado para un mercado. | ||||
|     /// </summary> | ||||
|     public interface IHolidayService | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Comprueba si la fecha dada es un feriado bursátil para el mercado especificado. | ||||
|         /// </summary> | ||||
|         /// <param name="marketCode">El código del mercado (ej. "BA" para Buenos Aires, "US" para EEUU).</param> | ||||
|         /// <param name="date">La fecha a comprobar.</param> | ||||
|         /// <returns>True si es feriado, false si no lo es.</returns> | ||||
|         Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								src/Mercados.Infrastructure/Services/INotificationService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/Mercados.Infrastructure/Services/INotificationService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| namespace Mercados.Infrastructure.Services | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Define un servicio para enviar notificaciones y alertas. | ||||
|     /// </summary> | ||||
|     public interface INotificationService | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Envía una alerta de fallo crítico. | ||||
|         /// </summary> | ||||
|         /// <param name="subject">El título de la alerta.</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); | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,7 @@ | ||||
| using Mercados.Infrastructure.DataFetchers; | ||||
| using Cronos; | ||||
| using Mercados.Infrastructure.DataFetchers; | ||||
| using Mercados.Infrastructure.Services; | ||||
| using Microsoft.Extensions.Configuration; | ||||
|  | ||||
| namespace Mercados.Worker | ||||
| { | ||||
| @@ -12,140 +14,182 @@ namespace Mercados.Worker | ||||
|         private readonly ILogger<DataFetchingService> _logger; | ||||
|         private readonly IServiceProvider _serviceProvider; | ||||
|         private readonly TimeZoneInfo _argentinaTimeZone; | ||||
|         private readonly IConfiguration _configuration; | ||||
|  | ||||
|         // Diccionario para rastrear la última vez que se ejecutó una tarea diaria | ||||
|         // y evitar que se ejecute múltiples veces si el servicio se reinicia. | ||||
|         private readonly Dictionary<string, DateTime> _lastDailyRun = new(); | ||||
|         /// <summary> | ||||
|         /// Expresión Cron para la tarea de Mercado Agroganadero. | ||||
|         /// </summary> | ||||
|         private readonly CronExpression _agroSchedule; | ||||
|         /// <summary> | ||||
|         /// Expresión Cron para la tarea de la Bolsa de Comercio de Rosario (BCR). | ||||
|         /// </summary> | ||||
|         private readonly CronExpression _bcrSchedule; | ||||
|         /// <summary> | ||||
|         /// Expresión Cron para la tarea de las Bolsas (Finnhub y Yahoo Finance). | ||||
|         /// </summary> | ||||
|         private readonly CronExpression _bolsasSchedule; | ||||
|         /// <summary> | ||||
|         /// Expresión Cron para la tarea de actualización de feriados. | ||||
|         /// </summary> | ||||
|         private readonly CronExpression _holidaysSchedule; | ||||
|  | ||||
|         public DataFetchingService(ILogger<DataFetchingService> logger, IServiceProvider serviceProvider,IConfiguration configuration) | ||||
|         /// <summary>Próxima hora de ejecución programada para la tarea de Mercado Agroganadero.</summary> | ||||
|         private DateTime? _nextAgroRun; | ||||
|         /// <summary>Próxima hora de ejecución programada para la tarea de BCR.</summary> | ||||
|         private DateTime? _nextBcrRun; | ||||
|         /// <summary>Próxima hora de ejecución programada para la tarea de Bolsas.</summary> | ||||
|         private DateTime? _nextBolsasRun; | ||||
|         /// <summary>Próxima hora de ejecución programada para la tarea de Feriados.</summary> | ||||
|         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(); | ||||
|         /// <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); | ||||
|  | ||||
|         /// <summary> | ||||
|         /// 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( | ||||
|             ILogger<DataFetchingService> logger, | ||||
|             IServiceProvider serviceProvider, | ||||
|             IConfiguration configuration) | ||||
|         { | ||||
|             _logger = logger; | ||||
|             _serviceProvider = serviceProvider; | ||||
|             _configuration = configuration; | ||||
|              | ||||
|  | ||||
|             // Se define explícitamente la zona horaria de Argentina. | ||||
|             // Esto asegura que los cálculos de tiempo sean correctos, sin importar | ||||
|             // la configuración de zona horaria del servidor donde se ejecute el worker. | ||||
|             try | ||||
|             { | ||||
|                 // El ID estándar para Linux y macOS | ||||
|                 _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); | ||||
|             } | ||||
|             catch (TimeZoneNotFoundException) | ||||
|             { | ||||
|                 // El ID equivalente para Windows | ||||
|                 _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); | ||||
|             } | ||||
|  | ||||
|             // Parseamos las expresiones Cron UNA SOLA VEZ, en el constructor. | ||||
|             // Si una expresión es inválida o nula, el servicio fallará al iniciar,  | ||||
|             // lo cual es un comportamiento deseable para alertar de una mala configuración. | ||||
|             // El '!' le dice al compilador que confiamos que estos valores no serán nulos. | ||||
|             _agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!); | ||||
|             _bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!); | ||||
|             _bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!); | ||||
|             _holidaysSchedule = CronExpression.Parse(configuration["Schedules:Holidays"]!); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca. | ||||
|         /// Método principal del servicio que se ejecuta en segundo plano. Contiene el bucle | ||||
|         /// principal que verifica periódicamente si se debe ejecutar alguna tarea programada. | ||||
|         /// </summary> | ||||
|         /// <param name="stoppingToken">Token de cancelación para detener el servicio de forma segura.</param> | ||||
|         protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|         { | ||||
|             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); | ||||
|  | ||||
|             // Se recomienda una ejecución inicial para poblar la base de datos inmediatamente | ||||
|             // al iniciar el servicio, en lugar de esperar al primer horario programado. | ||||
|             //await RunAllFetchersAsync(stoppingToken); | ||||
|             // La ejecución inicial sigue comentada | ||||
|             // await RunAllFetchersAsync(stoppingToken); | ||||
|  | ||||
|             // PeriodicTimer es una forma moderna y eficiente de crear un bucle de "tic-tac" | ||||
|             // sin bloquear un hilo con Task.Delay. | ||||
|             using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); | ||||
|             // Calculamos las primeras ejecuciones programadas al arrancar. | ||||
|             var utcNow = DateTime.UtcNow; | ||||
|             _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|             _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|             _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|             _nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|  | ||||
|             // Usamos un PeriodicTimer que "despierta" cada 30 segundos. | ||||
|             using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); | ||||
|  | ||||
|             // El bucle se ejecuta cada minuto mientras el servicio no reciba una señal de detención. | ||||
|             while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) | ||||
|             { | ||||
|                 await RunScheduledTasksAsync(stoppingToken); | ||||
|             } | ||||
|         } | ||||
|                 utcNow = DateTime.UtcNow; | ||||
|                 var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone); | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Revisa la hora actual y ejecuta las tareas que coincidan con su horario programado. | ||||
|         /// </summary> | ||||
|         private async Task RunScheduledTasksAsync(CancellationToken stoppingToken) | ||||
|         { | ||||
|             var utcNow = DateTime.UtcNow; | ||||
|              | ||||
|             // Tareas diarias (estas suelen ser rápidas y no se solapan, no es crítico paralelizar) | ||||
|             // Mantenerlas secuenciales puede ser más simple de leer. | ||||
|             string? agroSchedule = _configuration["Schedules:MercadoAgroganadero"]; | ||||
|             if (!string.IsNullOrEmpty(agroSchedule)) | ||||
|             { | ||||
|                 await TryRunDailyTaskAsync("MercadoAgroganadero", agroSchedule, utcNow, stoppingToken); | ||||
|             } | ||||
|             else { _logger.LogWarning("..."); } | ||||
|  | ||||
|             string? bcrSchedule = _configuration["Schedules:BCR"]; | ||||
|             if (!string.IsNullOrEmpty(bcrSchedule)) | ||||
|             { | ||||
|                 await TryRunDailyTaskAsync("BCR", bcrSchedule, utcNow, stoppingToken); | ||||
|             } | ||||
|             else { _logger.LogWarning("..."); } | ||||
|  | ||||
|             // --- Tareas Recurrentes (Bolsas) --- | ||||
|             string? bolsasSchedule = _configuration["Schedules:Bolsas"]; | ||||
|             if (!string.IsNullOrEmpty(bolsasSchedule)) | ||||
|             { | ||||
|                 // Reemplazamos la llamada secuencial con la ejecución paralela | ||||
|                 await TryRunRecurringTaskInParallelAsync(new[] { "YahooFinance", "Finnhub" }, bolsasSchedule, utcNow, stoppingToken); | ||||
|             } | ||||
|             else { _logger.LogWarning("..."); } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Comprueba y ejecuta una tarea que debe correr solo una vez al día. | ||||
|         /// </summary> | ||||
|         private async Task TryRunDailyTaskAsync(string taskName, string cronExpression, DateTime utcNow, CancellationToken stoppingToken) | ||||
|         { | ||||
|             var cron = CronExpression.Parse(cronExpression); | ||||
|             var nextOccurrence = cron.GetNextOccurrence(utcNow.AddMinutes(-1)); | ||||
|  | ||||
|             if (nextOccurrence.HasValue && nextOccurrence.Value <= utcNow) | ||||
|             { | ||||
|                 if (HasNotRunToday(taskName)) | ||||
|                 // Tarea de actualización de Feriados (semanal) | ||||
|                 if (_nextHolidaysRun.HasValue && utcNow >= _nextHolidaysRun.Value) | ||||
|                 { | ||||
|                     await RunFetcherByNameAsync(taskName, stoppingToken); | ||||
|                     _lastDailyRun[taskName] = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone).Date; | ||||
|                     _logger.LogInformation("Ejecutando tarea semanal de actualización de feriados."); | ||||
|                     await RunFetcherByNameAsync("Holidays", stoppingToken); | ||||
|                     _nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|                 } | ||||
|  | ||||
|                 // Tarea de Mercado Agroganadero (diaria) | ||||
|                 if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value) | ||||
|                 { | ||||
|                     // Comprueba si NO es feriado en Argentina para ejecutar | ||||
|                     if (!await IsMarketHolidayAsync("BA", nowInArgentina)) | ||||
|                     { | ||||
|                         await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); | ||||
|                     } | ||||
|                     else { _logger.LogInformation("Ejecución de MercadoAgroganadero omitida por ser feriado."); } | ||||
|  | ||||
|                     // Recalcula la próxima ejecución sin importar si corrió o fue feriado | ||||
|                     _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|                 } | ||||
|  | ||||
|                 // Tarea de Granos BCR (diaria) | ||||
|                 if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value) | ||||
|                 { | ||||
|                     if (!await IsMarketHolidayAsync("BA", nowInArgentina)) | ||||
|                     { | ||||
|                         await RunFetcherByNameAsync("BCR", stoppingToken); | ||||
|                     } | ||||
|                     else { _logger.LogInformation("Ejecución de BCR omitida por ser feriado."); } | ||||
|  | ||||
|                     _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|                 } | ||||
|  | ||||
|                 // Tarea de Bolsas (recurrente) | ||||
|                 if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value) | ||||
|                 { | ||||
|                     _logger.LogInformation("Ventana de ejecución para Bolsas detectada."); | ||||
|  | ||||
|                     var bolsaTasks = new List<Task>(); | ||||
|  | ||||
|                     // Comprueba el mercado local (Argentina) | ||||
|                     if (!await IsMarketHolidayAsync("BA", nowInArgentina)) | ||||
|                     { | ||||
|                         bolsaTasks.Add(RunFetcherByNameAsync("YahooFinance", stoppingToken)); | ||||
|                     } | ||||
|                     else { _logger.LogInformation("Ejecución de YahooFinance (Mercado Local) omitida por ser feriado."); } | ||||
|  | ||||
|                     // Comprueba el mercado de EEUU | ||||
|                     if (!await IsMarketHolidayAsync("US", nowInArgentina)) | ||||
|                     { | ||||
|                         bolsaTasks.Add(RunFetcherByNameAsync("Finnhub", stoppingToken)); | ||||
|                     } | ||||
|                     else { _logger.LogInformation("Ejecución de Finnhub (Mercado EEUU) omitida por ser feriado."); } | ||||
|  | ||||
|                     // Si hay alguna tarea para ejecutar, las lanza en paralelo | ||||
|                     if (bolsaTasks.Any()) | ||||
|                     { | ||||
|                         _logger.LogInformation("Iniciando {Count} fetcher(s) de bolsa en paralelo...", bolsaTasks.Count); | ||||
|                         await Task.WhenAll(bolsaTasks); | ||||
|                     } | ||||
|  | ||||
|                     _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Comprueba y ejecuta una tarea que puede correr múltiples veces al día. | ||||
|         /// </summary> | ||||
|         private async Task TryRunRecurringTaskInParallelAsync(string[] taskNames, string cronExpression, DateTime utcNow, CancellationToken stoppingToken) | ||||
|         { | ||||
|             var cron = CronExpression.Parse(cronExpression, CronFormat.IncludeSeconds); | ||||
|             var nextOccurrence = cron.GetNextOccurrence(utcNow.AddMinutes(-1)); | ||||
|              | ||||
|             if (nextOccurrence.HasValue && nextOccurrence.Value <= utcNow) | ||||
|             { | ||||
|                 _logger.LogInformation("Ventana de ejecución para: {Tasks}. Iniciando en paralelo...", string.Join(", ", taskNames)); | ||||
|                  | ||||
|                 // Creamos una lista de tareas, una por cada fetcher a ejecutar | ||||
|                 var tasks = taskNames.Select(taskName => RunFetcherByNameAsync(taskName, stoppingToken)).ToList(); | ||||
|                  | ||||
|                 // Iniciamos todas las tareas a la vez y esperamos a que todas terminen | ||||
|                 await Task.WhenAll(tasks); | ||||
|                  | ||||
|                 _logger.LogInformation("Todas las tareas recurrentes han finalizado."); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         /// <summary> | ||||
|         /// Ejecuta un fetcher específico por su nombre. Utiliza un scope de DI para gestionar | ||||
|         /// correctamente el ciclo de vida de los servicios (como las conexiones a la BD). | ||||
|         /// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones. | ||||
|         /// </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) | ||||
|         { | ||||
|             if (stoppingToken.IsCancellationRequested) return; | ||||
|  | ||||
|             _logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName); | ||||
|              | ||||
|             // Crea un "scope" de servicios. Todos los servicios "scoped" (como los repositorios) | ||||
|             // se crearán de nuevo para esta ejecución y se desecharán al final, evitando problemas. | ||||
|  | ||||
|             using var scope = _serviceProvider.CreateScope(); | ||||
|             var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); | ||||
|             var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase)); | ||||
| @@ -155,7 +199,19 @@ namespace Mercados.Worker | ||||
|                 var (success, message) = await fetcher.FetchDataAsync(); | ||||
|                 if (!success) | ||||
|                 { | ||||
|                     _logger.LogError("Falló la ejecución del fetcher {sourceName}: {message}", sourceName, message); | ||||
|                     var errorMessage = $"Falló la ejecución del fetcher {sourceName}: {message}"; | ||||
|                     _logger.LogError(errorMessage); | ||||
|  | ||||
|                     if (ShouldSendAlert(sourceName)) | ||||
|                     { | ||||
|                         var notifier = scope.ServiceProvider.GetRequiredService<INotificationService>(); | ||||
|                         await notifier.SendFailureAlertAsync($"Fallo Crítico en el Fetcher: {sourceName}", errorMessage, DateTime.UtcNow); | ||||
|                         _lastAlertSent[sourceName] = DateTime.UtcNow; | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         _logger.LogWarning("Fallo repetido para {sourceName}. Alerta silenciada temporalmente.", sourceName); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             else | ||||
| @@ -165,33 +221,55 @@ namespace Mercados.Worker | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Ejecuta todos los fetchers al iniciar el servicio. Esto es útil para poblar | ||||
|         /// la base de datos inmediatamente al arrancar el worker. | ||||
|         /// Ejecuta todos los fetchers en paralelo al iniciar el servicio. | ||||
|         /// </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) | ||||
|         { | ||||
|             _logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo..."); | ||||
|             using var scope = _serviceProvider.CreateScope(); | ||||
|             var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); | ||||
|              | ||||
|             // Creamos una lista de tareas, una por cada fetcher disponible | ||||
|             var tasks = fetchers.Select(fetcher => RunFetcherByNameAsync(fetcher.SourceName, stoppingToken)).ToList(); | ||||
|              | ||||
|             // Ejecutamos todo y esperamos | ||||
|  | ||||
|             var tasks = fetchers.Select(fetcher => RunFetcherByNameAsync(fetcher.SourceName, stoppingToken)); | ||||
|  | ||||
|             await Task.WhenAll(tasks); | ||||
|  | ||||
|             _logger.LogInformation("Ejecución inicial de todos los fetchers completada."); | ||||
|         } | ||||
|         */ | ||||
|          | ||||
|  | ||||
|         #region Funciones de Ayuda para la Planificación | ||||
|  | ||||
|         private bool HasNotRunToday(string taskName) | ||||
|         /// <summary> | ||||
|         /// Determina si se debe enviar una alerta o si está en período de silencio. | ||||
|         /// </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) | ||||
|         { | ||||
|             return !_lastDailyRun.ContainsKey(taskName) || _lastDailyRun[taskName].Date < TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone).Date; | ||||
|             if (!_lastAlertSent.ContainsKey(taskName)) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             var lastAlertTime = _lastAlertSent[taskName]; | ||||
|             return DateTime.UtcNow.Subtract(lastAlertTime) > _alertSilencePeriod; | ||||
|         } | ||||
|  | ||||
|         #endregion | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Comprueba si una fecha dada es feriado para un mercado específico. | ||||
|         /// </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) | ||||
|         { | ||||
|             using var scope = _serviceProvider.CreateScope(); | ||||
|             var holidayService = scope.ServiceProvider.GetRequiredService<IHolidayService>(); | ||||
|             return await holidayService.IsMarketHolidayAsync(marketCode, date); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								src/Mercados.Worker/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/Mercados.Worker/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| # --- Etapa 1: Build --- | ||||
| # Usamos la imagen del SDK completa para tener todas las herramientas de compilación | ||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||
| WORKDIR /src | ||||
|  | ||||
| # Copiamos los archivos .csproj y restauramos las dependencias | ||||
| COPY ["src/Mercados.Worker/Mercados.Worker.csproj", "Mercados.Worker/"] | ||||
| COPY ["src/Mercados.Infrastructure/Mercados.Infrastructure.csproj", "Mercados.Infrastructure/"] | ||||
| COPY ["src/Mercados.Core/Mercados.Core.csproj", "Mercados.Core/"] | ||||
| RUN dotnet restore "Mercados.Worker/Mercados.Worker.csproj" | ||||
|  | ||||
| # Copiamos el resto del código fuente | ||||
| COPY src/. . | ||||
|  | ||||
| # Publicamos la aplicación en modo Release | ||||
| WORKDIR "/src/Mercados.Worker" | ||||
| RUN dotnet publish "Mercados.Worker.csproj" -c Release -o /app/publish | ||||
|  | ||||
| # --- Etapa 2: Final --- | ||||
| # Usamos la imagen de runtime, que es más ligera | ||||
| FROM mcr.microsoft.com/dotnet/runtime:9.0 AS final | ||||
|  | ||||
| # Instalamos las librerías de soporte para globalización e ICU (International Components for Unicode) | ||||
| # Esto es necesario en imágenes de Linux minimalistas para poder usar codificaciones no-UTF8 como windows-1252. | ||||
| RUN apt-get update && apt-get install -y libicu-dev && rm -rf /var/lib/apt/lists/* | ||||
|  | ||||
| WORKDIR /app | ||||
| COPY --from=build /app/publish . | ||||
|  | ||||
| # El punto de entrada no cambia | ||||
| ENTRYPOINT ["dotnet", "Mercados.Worker.dll"] | ||||
| @@ -5,11 +5,11 @@ | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <UserSecretsId>dotnet-Mercados.Worker-0e9a6e84-c8fb-400b-ba7b-193d9981a046</UserSecretsId> | ||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Cronos" Version="0.11.0" /> | ||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   | ||||
| @@ -6,90 +6,50 @@ using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Mercados.Worker; | ||||
| using Polly; | ||||
| using Polly.Extensions.Http; | ||||
| using Mercados.Infrastructure.Services; | ||||
|  | ||||
| // Carga las variables de entorno desde el archivo .env en la raíz de la solución. | ||||
| DotNetEnv.Env.Load(); | ||||
|  | ||||
| Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); | ||||
| // --- Configuración del Host --- | ||||
| // Esto prepara el host del servicio, permitiendo la inyección de dependencias, | ||||
| // la configuración desde appsettings.json y el logging. | ||||
| IHost host = Host.CreateDefaultBuilder(args) | ||||
|     .ConfigureServices((hostContext, services) => | ||||
|     { | ||||
|         // Obtenemos la configuración desde el host builder para usarla aquí. | ||||
|         IConfiguration configuration = hostContext.Configuration; | ||||
|  | ||||
|         // --- 1. Registro de Servicios de Infraestructura --- | ||||
|  | ||||
|         // Registramos la fábrica de conexiones a la BD. Es un Singleton porque | ||||
|         // solo necesita ser creada una vez para leer la cadena de conexión. | ||||
|         // El resto del código no cambia. IConfiguration recogerá automáticamente | ||||
|         // las variables de entorno que cargamos correctamente. | ||||
|         services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); | ||||
|  | ||||
|         // Registramos los repositorios. Se crean "por petición" (Scoped). | ||||
|         // En un worker, "Scoped" significa que se creará una instancia por cada | ||||
|         // ejecución del servicio, lo cual es seguro y eficiente. | ||||
|         services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||
|         services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||
|         services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||
|         services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||
|         services.AddScoped<INotificationService, EmailNotificationService>(); | ||||
|  | ||||
|         // --- 2. Registro de los Data Fetchers --- | ||||
|  | ||||
|         // Registramos CADA uno de nuestros fetchers. El contenedor de DI sabrá | ||||
|         // que todos implementan la interfaz IDataFetcher. | ||||
|         services.AddScoped<IDataFetcher, MercadoAgroFetcher>(); | ||||
|         services.AddScoped<IDataFetcher, BcrDataFetcher>(); | ||||
|         services.AddScoped<IDataFetcher, FinnhubDataFetcher>(); | ||||
|         services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>(); | ||||
|  | ||||
|         // El cliente HTTP es fundamental para hacer llamadas a APIs externas. | ||||
|         // Le damos un nombre al cliente de Finnhub para cumplir con los requisitos de su constructor. | ||||
|         //services.AddHttpClient("Finnhub"); | ||||
|          | ||||
|          // Configuramos CADA cliente HTTP que nuestros fetchers usan. | ||||
|         // IHttpClientFactory nos permite nombrar y configurar clientes de forma independiente. | ||||
|          | ||||
|         // Cliente para el scraper del MercadoAgro, con una política de reintentos | ||||
|         services.AddHttpClient("MercadoAgroFetcher") | ||||
|             .AddPolicyHandler(GetRetryPolicy()); | ||||
|         services.AddHttpClient("MercadoAgroFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||
|         services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||
|         services.AddHttpClient("FinnhubDataFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||
|  | ||||
|         // Cliente para la API de BCR, con la misma política de reintentos | ||||
|         services.AddHttpClient("BcrDataFetcher") | ||||
|             .AddPolicyHandler(GetRetryPolicy()); | ||||
|              | ||||
|         // Cliente para Finnhub, con la misma política de reintentos | ||||
|         services.AddHttpClient("FinnhubDataFetcher") | ||||
|             .AddPolicyHandler(GetRetryPolicy()); | ||||
|              | ||||
|         // Cliente para YahooFinance (aunque es menos probable que falle, es buena práctica incluirlo) | ||||
|         // La librería YahooFinanceApi usa su propio HttpClient, así que esta configuración | ||||
|         // no le afectará directamente. La resiliencia para YahooFinance la manejaremos de otra forma si es necesario. | ||||
|         // Por ahora, lo dejamos así y nos enfocamos en los que usan IHttpClientFactory. | ||||
|         // Servicio de caché en memoria de .NET | ||||
|         services.AddMemoryCache(); | ||||
|         // Registramos nuestro nuevo servicio de feriados | ||||
|         services.AddScoped<IHolidayService, FinnhubHolidayService>(); | ||||
|         services.AddScoped<IDataFetcher, HolidayDataFetcher>(); | ||||
|         services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>(); | ||||
|  | ||||
|  | ||||
|         // --- 3. Registro del Worker Principal --- | ||||
|  | ||||
|         // Finalmente, registramos nuestro servicio de fondo (el worker en sí). | ||||
|         services.AddHostedService<DataFetchingService>(); | ||||
|     }) | ||||
|     .Build(); | ||||
|  | ||||
| // Esta función define nuestra política de reintentos. | ||||
| static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() | ||||
| { | ||||
|     // Polly.Extensions.Http nos da este método conveniente. | ||||
|     return HttpPolicyExtensions | ||||
|         // Maneja errores de red transitorios O códigos de estado de servidor que indican un problema temporal. | ||||
|         .HandleTransientHttpError()  | ||||
|         // También maneja el error 408 Request Timeout | ||||
|         .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.RequestTimeout)  | ||||
|         // Política de reintento con espera exponencial: 3 reintentos, esperando 2^intento segundos. | ||||
|         .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),  | ||||
|         .HandleTransientHttpError() | ||||
|         .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.RequestTimeout) | ||||
|         .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), | ||||
|             onRetry: (outcome, timespan, retryAttempt, context) => | ||||
|             { | ||||
|                 // Registramos un log cada vez que se realiza un reintento. | ||||
|                 // Esta es una forma de hacerlo sin tener acceso directo al ILogger aquí. | ||||
|                 Console.WriteLine($"[Polly] Reintentando petición... Intento {retryAttempt}. Esperando {timespan.TotalSeconds}s. Causa: {outcome.Exception?.Message ?? outcome.Result.ReasonPhrase}"); | ||||
|             }); | ||||
| } | ||||
|   | ||||
| @@ -12,7 +12,8 @@ | ||||
|   "Schedules": { | ||||
|     "MercadoAgroganadero": "0 11 * * 1-5", | ||||
|     "BCR": "30 11 * * 1-5", | ||||
|     "Bolsas": "10 11-17 * * 1-5" | ||||
|     "Bolsas": "10 11-17 * * 1-5", | ||||
|     "Holidays": "0 2 * * 1" | ||||
|   }, | ||||
|   "ApiKeys": { | ||||
|     "Finnhub": "", | ||||
| @@ -20,5 +21,13 @@ | ||||
|       "Key": "", | ||||
|       "Secret": "" | ||||
|     } | ||||
|   }, | ||||
|   "SmtpSettings": { | ||||
|     "Host": "", | ||||
|     "Port": 587, | ||||
|     "User": "", | ||||
|     "Pass": "", | ||||
|     "SenderName": "Servicio de Mercados", | ||||
|     "Recipient": "" | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user