Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 363f71282f | |||
| fb872f0889 | |||
| fbe07b7ea2 | |||
| 2a59edf050 | |||
| 7c5d66665e | |||
| 697f093ef1 | |||
| 5ebf4a4320 | |||
| 67a2f3f449 | |||
| efd0dfb574 | |||
| c10924bc35 | |||
| 24a6fc3849 | |||
| 134c9399e9 | |||
| 9dbf8dbe7e | |||
| c778816efd | |||
| 681809387e | |||
| aadd0b218b | |||
| f087799191 | |||
| e9540fa155 | |||
| 23f0d02fe3 |
34
.env
Normal file
34
.env
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# =======================================================
|
||||||
|
# == Fichero de Entorno para la Aplicación de Mercados ==
|
||||||
|
# =======================================================
|
||||||
|
# Este fichero debe estar en /opt/mercados-app/.env en el servidor.
|
||||||
|
# Contiene todos los secretos y configuraciones específicas del entorno.
|
||||||
|
|
||||||
|
# --- Conexión a la Base de Datos ---
|
||||||
|
# Cadena de conexión para SQL Server.
|
||||||
|
# IMPORTANTE: El 'Server' debe ser el nombre del servicio de la base de datos en la red Docker compartida.
|
||||||
|
# Según tu arquitectura, es 'db-sqlserver'.
|
||||||
|
ConnectionStrings__DefaultConnection="Server=db-sqlserver;Database=MercadosDb;User ID=mercadosuser;Password=@mercados1351@;TrustServerCertificate=True;Encrypt=False;"
|
||||||
|
|
||||||
|
# --- Horarios de Ejecución del Worker (formato Cron) ---
|
||||||
|
Schedules__MercadoAgroganadero="0 10,12,15,18 * * 1-5"
|
||||||
|
Schedules__BCR="30 11 * * 1-5"
|
||||||
|
Schedules__Bolsas="10 11-17 * * 1-5"
|
||||||
|
|
||||||
|
# --- Claves de APIs Externas ---
|
||||||
|
ApiKeys__Finnhub="d1jsl99r01ql1h397eh0d1jsl99r01ql1h397ehg"
|
||||||
|
ApiKeys__Bcr__Key="D1782A51-A5FD-EF11-9445-00155D09E201"
|
||||||
|
ApiKeys__Bcr__Secret="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"
|
||||||
|
|
||||||
|
# --- Configuración de Email para Alertas (SMTP) ---
|
||||||
|
SmtpSettings__Host="192.168.5.201"
|
||||||
|
SmtpSettings__Port="587"
|
||||||
|
SmtpSettings__User="alertas@eldia.com"
|
||||||
|
SmtpSettings__Pass="@Alertas713550@"
|
||||||
|
SmtpSettings__SenderName="Servicio de Mercados"
|
||||||
|
SmtpSettings__Recipient="tecnica@eldia.com"
|
||||||
|
|
||||||
|
# --- Configuración del Entorno de ASP.NET Core ---
|
||||||
|
# Esto asegura que la aplicación se ejecute en modo Producción,
|
||||||
|
# desactivando páginas de error detalladas, etc.
|
||||||
|
ASPNETCORE_ENVIRONMENT="Production"
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -178,6 +178,8 @@ DocProject/Help/*.hhk
|
|||||||
DocProject/Help/*.hhp
|
DocProject/Help/*.hhp
|
||||||
DocProject/Help/Html2
|
DocProject/Help/Html2
|
||||||
DocProject/Help/html
|
DocProject/Help/html
|
||||||
|
# DocFx
|
||||||
|
[Dd]ocs/
|
||||||
|
|
||||||
# Click-Once directory
|
# Click-Once directory
|
||||||
publish/
|
publish/
|
||||||
@@ -414,3 +416,6 @@ FodyWeavers.xsd
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env
|
||||||
|
.env
|
||||||
|
|||||||
150
README.md
150
README.md
@@ -1,3 +1,149 @@
|
|||||||
# Mercados-Web
|
---
|
||||||
|
|
||||||
API y Worker para la recolección y exposición de datos de mercados financieros.
|
# Proyecto de Widgets de Mercados Financieros
|
||||||
|
|
||||||
|
Este repositorio contiene la solución completa para la recolección, almacenamiento y visualización de datos de mercados financieros. La arquitectura está diseñada para ser robusta, escalable y fácil de mantener, separando las responsabilidades en un backend de .NET, un worker de fondo, y un frontend de React basado en widgets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Arquitectura General
|
||||||
|
|
||||||
|
El sistema se compone de los siguientes elementos principales:
|
||||||
|
|
||||||
|
1. **Backend (`Mercados.Api`):** Una API web de .NET 9 que expone los datos almacenados en la base de datos a través de endpoints RESTful.
|
||||||
|
2. **Worker Service (`Mercados.Worker`):** Un servicio de fondo de .NET 9 responsable de obtener los datos de fuentes externas (`Finnhub`, `Bolsa de Comercio de Rosario`, `Yahoo Finance` y scraping) de forma programada, procesarlos y guardarlos en la base de datos.
|
||||||
|
3. **Frontend (`frontend/`):** Una aplicación de React (construida con Vite + TypeScript) que proporciona un conjunto de "widgets" modulares y portátiles para visualizar los datos.
|
||||||
|
4. **Base de Datos (`Mercados.Database`):** Utiliza SQL Server para la persistencia de los datos. El esquema es gestionado a través de migraciones con **FluentMigrator**.
|
||||||
|
5. **Documentación(En Proceso) (`docs/`):** Un portal de documentación unificado generado con **DocFX**, **TypeDoc** y **Storybook**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Configuración del Entorno de Desarrollo
|
||||||
|
|
||||||
|
Para ejecutar este proyecto en un entorno local, necesitarás tener instalado:
|
||||||
|
|
||||||
|
- **.NET 9 SDK**
|
||||||
|
- **Node.js** (v20 o superior) y npm
|
||||||
|
- **SQL Server** (Developer o Express Edition)
|
||||||
|
- **Docker y Docker Compose** (Opcional, para simular el entorno de producción)
|
||||||
|
- **DocFX Command Line Tool** (Instalar con: `dotnet tool install -g docfx`)
|
||||||
|
|
||||||
|
### Pasos de Configuración Inicial:
|
||||||
|
|
||||||
|
1. **Clonar el Repositorio:**
|
||||||
|
```bash
|
||||||
|
git clone [URL_DEL_REPOSITORIO_GITEA]
|
||||||
|
cd [NOMBRE_DE_LA_CARPETA_DONDE_SE_CLONÓ]
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configurar Secretos del Backend:**
|
||||||
|
Este proyecto utiliza "User Secrets" para manejar información sensible en desarrollo. Configúralos desde la raíz del repositorio:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Habilitar secretos para la API y el Worker
|
||||||
|
dotnet user-secrets init --project src/Mercados.Api
|
||||||
|
dotnet user-secrets init --project src/Mercados.Worker
|
||||||
|
|
||||||
|
# Establecer los valores (reemplaza con tus datos)
|
||||||
|
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=...;Database=..." --project src/Mercados.Api
|
||||||
|
dotnet user-secrets set "ApiKeys:Finnhub" "tu_api_key" --project src/Mercados.Api
|
||||||
|
# ...añadir el resto de las claves para ApiKeys y SmtpSettings
|
||||||
|
# Repite el proceso para el Worker
|
||||||
|
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "..." --project src/Mercados.Worker
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Instalar Dependencias del Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Crear y Migrar la Base de Datos:**
|
||||||
|
Simplemente ejecuta la API una vez. La primera vez que se inicie, FluentMigrator creará la base de datos `MercadosDb` y aplicará todas las migraciones necesarias.
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/Mercados.Api
|
||||||
|
```
|
||||||
|
Puedes detenerla con `Ctrl+C` una vez que veas el mensaje "Application started".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏃 Cómo Ejecutar el Proyecto
|
||||||
|
|
||||||
|
Para trabajar en el proyecto, necesitarás ejecutar los 3 servicios principales en terminales separadas.
|
||||||
|
|
||||||
|
1. **Iniciar el Backend API:**
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/Mercados.Api
|
||||||
|
```
|
||||||
|
*La API estará disponible (por defecto) en `http://localhost:5045`.*
|
||||||
|
|
||||||
|
2. **Iniciar el Worker Service:**
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/Mercados.Worker
|
||||||
|
```
|
||||||
|
*El worker comenzará a obtener datos según los horarios definidos en `appsettings.json`.*
|
||||||
|
|
||||||
|
3. **Iniciar el Frontend (Servidor de Desarrollo):**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
*La aplicación de React estará disponible en `http://localhost:5173`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentación
|
||||||
|
|
||||||
|
Este proyecto incluye un portal de documentación unificado que cubre tanto el backend como el frontend.
|
||||||
|
|
||||||
|
### Para Generar la Documentación:
|
||||||
|
|
||||||
|
1. **Compilar el Backend:**
|
||||||
|
```bash
|
||||||
|
# Desde la raíz
|
||||||
|
dotnet build
|
||||||
|
```
|
||||||
|
2. **Generar la documentación TypeScript:**
|
||||||
|
```bash
|
||||||
|
# Desde la carpeta /frontend
|
||||||
|
npm run doc:ts
|
||||||
|
```
|
||||||
|
3. **Construir el sitio DocFX:**
|
||||||
|
```bash
|
||||||
|
# Desde la raíz, usando el archivo de configuración en /docs
|
||||||
|
docfx docs/docfx.json
|
||||||
|
```
|
||||||
|
4. **Visualizar la documentación:**
|
||||||
|
```bash
|
||||||
|
# Desde la raíz
|
||||||
|
docfx serve docs/_site
|
||||||
|
```
|
||||||
|
*Abre tu navegador en `http://localhost:8080`.*
|
||||||
|
|
||||||
|
### Para ver la Librería de Componentes Visuales (Storybook):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desde la carpeta /frontend
|
||||||
|
npm run storybook
|
||||||
|
```
|
||||||
|
*Abre tu navegador en `http://localhost:6006`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Despliegue en Producción (Docker)
|
||||||
|
|
||||||
|
El despliegue está diseñado para ser realizado con Docker y Docker Compose.
|
||||||
|
|
||||||
|
1. **Construir las imágenes de Docker:**
|
||||||
|
```bash
|
||||||
|
# Desde la raíz del proyecto
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
2. **Lanzar el stack de servicios:**
|
||||||
|
Asegúrate de tener el archivo `.env` configurado en el servidor de producción antes de ejecutar.
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
El `docker-compose.yml` está configurado para conectarse a una red externa `shared-net` para la base de datos y para ser gestionado por un proxy inverso externo como Nginx Proxy Manager.
|
||||||
BIN
Widgets-ELDIA-1.1.pdf
Normal file
BIN
Widgets-ELDIA-1.1.pdf
Normal file
Binary file not shown.
22
docfx.build.json
Normal file
22
docfx.build.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"docs/api/**.yml",
|
||||||
|
"docs/toc.yml",
|
||||||
|
"docs/index.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resource": [],
|
||||||
|
"output": "docs/_site",
|
||||||
|
"template": [
|
||||||
|
"default",
|
||||||
|
"modern"
|
||||||
|
],
|
||||||
|
"globalMetadata": {
|
||||||
|
"_appTitle": "Documentación Proyecto Mercados"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
docfx.metadata.json
Normal file
19
docfx.metadata.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"metadata": [
|
||||||
|
{
|
||||||
|
"src": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"src/Mercados.Api/Mercados.Api.csproj",
|
||||||
|
"src/Mercados.Core/Mercados.Core.csproj",
|
||||||
|
"src/Mercados.Database/Mercados.Database.csproj",
|
||||||
|
"src/Mercados.Infrastructure/Mercados.Infrastructure.csproj",
|
||||||
|
"src/Mercados.Worker/Mercados.Worker.csproj"
|
||||||
|
],
|
||||||
|
"src": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dest": "docs/api"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ services:
|
|||||||
# Servicio del Backend API
|
# Servicio del Backend API
|
||||||
mercados-api:
|
mercados-api:
|
||||||
build:
|
build:
|
||||||
context: ./Mercados-Web # Asumiendo que clonaste el repo en esta carpeta
|
context: ./Mercados-Web
|
||||||
dockerfile: src/Mercados.Api/Dockerfile
|
dockerfile: src/Mercados.Api/Dockerfile
|
||||||
container_name: mercados-api
|
container_name: mercados-api
|
||||||
restart: always
|
restart: always
|
||||||
@@ -60,4 +60,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
shared-net:
|
shared-net:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
# Directorio raíz donde están los archivos de la app
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Configuración para servir los archivos estáticos y manejar el enrutamiento de la SPA
|
# --- BLOQUE PARA BOOTSTRAP.JS (MEJORADO) ---
|
||||||
location / {
|
# Se aplica EXCLUSIVAMENTE a la petición de /bootstrap.js
|
||||||
# Intenta servir el archivo solicitado directamente ($uri),
|
location = /bootstrap.js {
|
||||||
# luego como un directorio ($uri/),
|
# Aseguramos que Nginx genere la huella digital ETag
|
||||||
# y si no encuentra nada, devuelve el index.html
|
etag on;
|
||||||
# Esto es crucial para que el enrutamiento de React funcione.
|
|
||||||
try_files $uri $uri/ /index.html;
|
# Instrucciones explícitas de no cachear
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate, private";
|
||||||
|
|
||||||
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Opcional: optimizaciones para archivos estáticos
|
# Bloque para otros activos con hash (con caché agresiva)
|
||||||
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2)$ {
|
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bloque para el enrutamiento de la aplicación React
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
37
frontend/public/bootstrap.js
vendored
37
frontend/public/bootstrap.js
vendored
@@ -1,5 +1,4 @@
|
|||||||
// frontend/public/bootstrap.js
|
// frontend/public/bootstrap.js
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
// El dominio donde se alojan los widgets
|
// El dominio donde se alojan los widgets
|
||||||
const WIDGETS_HOST = 'https://widgets.eldia.com';
|
const WIDGETS_HOST = 'https://widgets.eldia.com';
|
||||||
@@ -27,17 +26,17 @@
|
|||||||
// Función principal
|
// Función principal
|
||||||
async function initWidgets() {
|
async function initWidgets() {
|
||||||
try {
|
try {
|
||||||
// 1. Obtener el manifest.json para saber los nombres de archivo actuales
|
// 1. Obtener el manifest.json
|
||||||
const response = await fetch(`${WIDGETS_HOST}/manifest.json`);
|
const response = await fetch(`${WIDGETS_HOST}/manifest.json`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('No se pudo cargar el manifest de los widgets.');
|
throw new Error('No se pudo cargar el manifest de los widgets de mercados.');
|
||||||
}
|
}
|
||||||
const manifest = await response.json();
|
const manifest = await response.json();
|
||||||
|
|
||||||
// 2. Encontrar el punto de entrada principal (nuestro main.tsx)
|
// 2. Encontrar el punto de entrada principal
|
||||||
const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry);
|
const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry);
|
||||||
if (!entryKey) {
|
if (!entryKey) {
|
||||||
throw new Error('No se encontró el punto de entrada en el manifest.');
|
throw new Error('No se encontró el punto de entrada en el manifest de mercados.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = manifest[entryKey];
|
const entry = manifest[entryKey];
|
||||||
@@ -51,12 +50,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Cargar el JS principal y esperar a que esté listo
|
// 4. Cargar el JS principal
|
||||||
await loadScript(jsUrl);
|
await loadScript(jsUrl);
|
||||||
|
|
||||||
// 5. Una vez cargado, llamar a la función de renderizado
|
// 5. Una vez cargado, llamar a la función de renderizado para CADA WIDGET
|
||||||
if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') {
|
if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') {
|
||||||
window.MercadosWidgets.render();
|
console.log('Bootstrap Mercados: La función render existe. Renderizando widgets...');
|
||||||
|
|
||||||
|
// Buscamos los contenedores específicos para los widgets de mercados
|
||||||
|
const widgetContainers = document.querySelectorAll('[data-mercado-widget]');
|
||||||
|
|
||||||
|
if (widgetContainers.length === 0) {
|
||||||
|
console.warn('Bootstrap Mercados: No se encontraron contenedores de widget en la página.');
|
||||||
|
}
|
||||||
|
|
||||||
|
widgetContainers.forEach(container => {
|
||||||
|
// Pasamos el contenedor y su dataset (props) a la función de renderizado
|
||||||
|
window.MercadosWidgets.render(container, container.dataset);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Bootstrap Mercados: La función window.MercadosWidgets.render no fue encontrada.');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -64,7 +77,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iniciar todo el proceso
|
// Esperamos a que el DOM esté listo para ejecutar
|
||||||
initWidgets();
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initWidgets);
|
||||||
|
} else {
|
||||||
|
initWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
@@ -27,7 +27,7 @@ const Variacion = ({ value }: { value: number }) => {
|
|||||||
return (
|
return (
|
||||||
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
|
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
|
||||||
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
|
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
|
||||||
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography>
|
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const Variacion = ({ value }: { value: number }) => {
|
|||||||
return (
|
return (
|
||||||
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
|
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
|
||||||
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
|
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
|
||||||
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography>
|
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,29 @@
|
|||||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
|
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography } from '@mui/material';
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
|
||||||
// Importaciones de nuestro proyecto
|
// Importaciones de nuestro proyecto
|
||||||
import { useApiData } from '../../hooks/useApiData';
|
import { useApiData } from '../../hooks/useApiData';
|
||||||
import { useIsHoliday } from '../../hooks/useIsHoliday';
|
import { useIsHoliday } from '../../hooks/useIsHoliday';
|
||||||
import type { CotizacionGanado } from '../../models/mercadoModels';
|
import type { CotizacionGanado } from '../../models/mercadoModels';
|
||||||
import { formatInteger, formatCurrency, formatFullDateTime } from '../../utils/formatters';
|
import { formatInteger, formatFullDateTime } from '../../utils/formatters';
|
||||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||||
import { HolidayAlert } from '../common/HolidayAlert';
|
import { HolidayAlert } from '../common/HolidayAlert';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Función para convertir los datos de la tabla a un formato CSV para el portapapeles.
|
* Función para convertir los datos a formato TSV (Tab-Separated Values)
|
||||||
|
* con el formato específico solicitado por redacción.
|
||||||
*/
|
*/
|
||||||
const toCSV = (headers: string[], data: CotizacionGanado[]) => {
|
const toTSV = (data: CotizacionGanado[]) => {
|
||||||
const headerRow = headers.join(';');
|
const dataRows = data.map(row => {
|
||||||
const dataRows = data.map(row =>
|
// Unimos Categoría y Especificaciones en una sola columna para el copiado
|
||||||
[
|
const categoriaCompleta = `${row.categoria}/${row.especificaciones}`;
|
||||||
row.categoria,
|
const cabezas = formatInteger(row.cabezas);
|
||||||
row.especificaciones,
|
const importeTotal = formatInteger(row.importeTotal);
|
||||||
formatCurrency(row.maximo),
|
|
||||||
formatCurrency(row.minimo),
|
return [categoriaCompleta, cabezas, importeTotal].join('\t');
|
||||||
formatCurrency(row.mediano),
|
});
|
||||||
formatInteger(row.cabezas),
|
|
||||||
formatInteger(row.kilosTotales),
|
return dataRows.join('\n');
|
||||||
formatInteger(row.importeTotal),
|
|
||||||
formatFullDateTime(row.fechaRegistro)
|
|
||||||
].join(';')
|
|
||||||
);
|
|
||||||
return [headerRow, ...dataRows].join('\n');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,24 +31,21 @@ const toCSV = (headers: string[], data: CotizacionGanado[]) => {
|
|||||||
* diseñado para la página de redacción.
|
* diseñado para la página de redacción.
|
||||||
*/
|
*/
|
||||||
export const RawAgroTable = () => {
|
export const RawAgroTable = () => {
|
||||||
// Hooks para obtener los datos y el estado de feriado.
|
|
||||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||||
const isHoliday = useIsHoliday('BA');
|
const isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total", "Fecha de Registro"];
|
const tsvData = toTSV(data);
|
||||||
const csvData = toCSV(headers, data);
|
|
||||||
|
|
||||||
copyToClipboard(csvData)
|
copyToClipboard(tsvData)
|
||||||
.then(() => alert('¡Tabla copiada al portapapeles!'))
|
.then(() => alert('Datos del Mercado Agroganadero copiados al portapapeles!'))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Error al copiar:', err);
|
console.error('Error al copiar:', err);
|
||||||
alert('Error: No se pudo copiar la tabla.');
|
alert('Error: No se pudo copiar la tabla.');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Estado de carga unificado.
|
|
||||||
const isLoading = dataLoading || isHoliday === null;
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
|
|
||||||
if (isLoading) return <CircularProgress />;
|
if (isLoading) return <CircularProgress />;
|
||||||
@@ -67,43 +60,37 @@ export const RawAgroTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */}
|
|
||||||
{isHoliday && (
|
{isHoliday && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<HolidayAlert />
|
<HolidayAlert />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
Copiar como CSV
|
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
|
||||||
</Button>
|
Copiar Datos para Redacción
|
||||||
|
</Button>
|
||||||
|
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||||
|
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* La tabla ahora muestra solo las columnas requeridas para facilitar la visualización */}
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Categoría</TableCell>
|
<TableCell>Categoría / Especificaciones</TableCell>
|
||||||
<TableCell>Especificaciones</TableCell>
|
|
||||||
<TableCell align="right">Máximo</TableCell>
|
|
||||||
<TableCell align="right">Mínimo</TableCell>
|
|
||||||
<TableCell align="right">Mediano</TableCell>
|
|
||||||
<TableCell align="right">Cabezas</TableCell>
|
<TableCell align="right">Cabezas</TableCell>
|
||||||
<TableCell align="right">Kg Total</TableCell>
|
|
||||||
<TableCell align="right">Importe Total</TableCell>
|
<TableCell align="right">Importe Total</TableCell>
|
||||||
<TableCell>Última Act.</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map(row => (
|
{data.map(row => (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell>{row.categoria}</TableCell>
|
<TableCell>{row.categoria} / {row.especificaciones}</TableCell>
|
||||||
<TableCell>{row.especificaciones}</TableCell>
|
|
||||||
<TableCell align="right">${formatCurrency(row.maximo)}</TableCell>
|
|
||||||
<TableCell align="right">${formatCurrency(row.minimo)}</TableCell>
|
|
||||||
<TableCell align="right">${formatCurrency(row.mediano)}</TableCell>
|
|
||||||
<TableCell align="right">{formatInteger(row.cabezas)}</TableCell>
|
<TableCell align="right">{formatInteger(row.cabezas)}</TableCell>
|
||||||
<TableCell align="right">{formatInteger(row.kilosTotales)}</TableCell>
|
|
||||||
<TableCell align="right">${formatInteger(row.importeTotal)}</TableCell>
|
<TableCell align="right">${formatInteger(row.importeTotal)}</TableCell>
|
||||||
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -1,105 +1,124 @@
|
|||||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
|
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Divider } from '@mui/material';
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
|
||||||
// Importaciones de nuestro proyecto
|
|
||||||
import { useApiData } from '../../hooks/useApiData';
|
import { useApiData } from '../../hooks/useApiData';
|
||||||
import { useIsHoliday } from '../../hooks/useIsHoliday';
|
|
||||||
import type { CotizacionBolsa } from '../../models/mercadoModels';
|
import type { CotizacionBolsa } from '../../models/mercadoModels';
|
||||||
import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
|
import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
|
||||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||||
import { HolidayAlert } from '../common/HolidayAlert';
|
import { TICKERS_PRIORITARIOS_LOCAL } from '../../config/priorityTickers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Función para convertir los datos de la tabla a formato CSV.
|
* Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values).
|
||||||
*/
|
*/
|
||||||
const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
|
const toTSV = (data: CotizacionBolsa[]) => {
|
||||||
const headerRow = headers.join(';');
|
const dataRows = data.map(row => {
|
||||||
const dataRows = data.map(row =>
|
// Formateamos el nombre para que quede como "GGAL.BA (GRUPO FINANCIERO GALICIA)"
|
||||||
[
|
const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`;
|
||||||
row.ticker,
|
const precio = `$${formatCurrency(row.precioActual)}`
|
||||||
row.nombreEmpresa,
|
const cambio = `${formatCurrency(row.porcentajeCambio)}%` //`${row.porcentajeCambio.toFixed(2)}%`
|
||||||
formatCurrency(row.precioActual),
|
|
||||||
formatCurrency(row.cierreAnterior),
|
// Unimos los campos con un carácter de tabulación '\t'
|
||||||
`${row.porcentajeCambio.toFixed(2)}%`,
|
return [nombreCompleto, precio, cambio].join('\t');
|
||||||
formatFullDateTime(row.fechaRegistro)
|
});
|
||||||
].join(';')
|
|
||||||
);
|
// Unimos todas las filas con un salto de línea
|
||||||
return [headerRow, ...dataRows].join('\n');
|
return dataRows.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Componente de tabla de datos crudos para la Bolsa Local (MERVAL y acciones),
|
* Componente de tabla de datos crudos para la Bolsa Local, adaptado para redacción.
|
||||||
* diseñado para la página de redacción.
|
|
||||||
*/
|
*/
|
||||||
export const RawBolsaLocalTable = () => {
|
export const RawBolsaLocalTable = () => {
|
||||||
// Hooks para obtener los datos y el estado de feriado.
|
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
|
||||||
const isHoliday = useIsHoliday('BA');
|
// Separamos los datos en prioritarios y el resto
|
||||||
|
const priorityData = data?.filter(d => TICKERS_PRIORITARIOS_LOCAL.includes(d.ticker))
|
||||||
|
.sort((a, b) => TICKERS_PRIORITARIOS_LOCAL.indexOf(a.ticker) - TICKERS_PRIORITARIOS_LOCAL.indexOf(b.ticker)); // Mantenemos el orden
|
||||||
|
|
||||||
|
const otherData = data?.filter(d => !TICKERS_PRIORITARIOS_LOCAL.includes(d.ticker));
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!data) return;
|
if (!priorityData) return;
|
||||||
const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %", "Fecha de Registro"];
|
const tsvData = toTSV(priorityData);
|
||||||
const csvData = toCSV(headers, data);
|
|
||||||
|
copyToClipboard(tsvData)
|
||||||
copyToClipboard(csvData)
|
.then(() => alert('Datos prioritarios copiados al portapapeles.'))
|
||||||
.then(() => alert('¡Tabla copiada al portapapeles!'))
|
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Error al copiar:', err);
|
console.error('Error al copiar:', err);
|
||||||
alert('Error: No se pudo copiar la tabla.');
|
alert('Error: No se pudo copiar la tabla.');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Estado de carga unificado.
|
if (loading) return <CircularProgress />;
|
||||||
const isLoading = dataLoading || isHoliday === null;
|
if (error) return <Alert severity="error">{error}</Alert>;
|
||||||
|
if (!data || data.length === 0) return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>;
|
||||||
if (isLoading) return <CircularProgress />;
|
|
||||||
if (dataError) return <Alert severity="error">{dataError}</Alert>;
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
if (isHoliday) {
|
|
||||||
return <HolidayAlert />;
|
|
||||||
}
|
|
||||||
return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */}
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
{isHoliday && (
|
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
|
||||||
<Box sx={{ mb: 2 }}>
|
Copiar Datos Principales
|
||||||
<HolidayAlert />
|
</Button>
|
||||||
</Box>
|
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||||
)}
|
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
{/* Tabla de Datos Prioritarios */}
|
||||||
Copiar como CSV
|
|
||||||
</Button>
|
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Ticker</TableCell>
|
<TableCell>Símbolo (Nombre)</TableCell>
|
||||||
<TableCell>Nombre</TableCell>
|
<TableCell align="right">Precio Actual</TableCell>
|
||||||
<TableCell align="right">Último Precio</TableCell>
|
<TableCell align="right">% Cambio</TableCell>
|
||||||
<TableCell align="right">Cierre Anterior</TableCell>
|
|
||||||
<TableCell align="right">Variación %</TableCell>
|
|
||||||
<TableCell>Última Act.</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map(row => (
|
{priorityData?.map(row => (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell>{row.ticker}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{row.nombreEmpresa}</TableCell>
|
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
|
||||||
|
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
|
||||||
|
</TableCell>
|
||||||
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||||
<TableCell align="right">${formatCurrency(row.cierreAnterior)}</TableCell>
|
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
|
||||||
<TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell>
|
|
||||||
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|
||||||
|
{/* Sección para Otros Tickers (solo para consulta) */}
|
||||||
|
{otherData && otherData.length > 0 && (
|
||||||
|
<Box mt={4}>
|
||||||
|
<Divider sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="overline">Otros Tickers (Solo Consulta)</Typography>
|
||||||
|
</Divider>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Símbolo (Nombre)</TableCell>
|
||||||
|
<TableCell align="right">Precio Actual</TableCell>
|
||||||
|
<TableCell align="right">% Cambio</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{otherData.map(row => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
|
||||||
|
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||||
|
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
|
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Divider } from '@mui/material';
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
|
||||||
// Importaciones de nuestro proyecto
|
// Importaciones de nuestro proyecto
|
||||||
@@ -8,98 +8,128 @@ import type { CotizacionBolsa } from '../../models/mercadoModels';
|
|||||||
import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
|
import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
|
||||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||||
import { HolidayAlert } from '../common/HolidayAlert';
|
import { HolidayAlert } from '../common/HolidayAlert';
|
||||||
|
import { TICKERS_PRIORITARIOS_USA } from '../../config/priorityTickers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Función para convertir los datos de la tabla a formato CSV.
|
* Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values).
|
||||||
*/
|
*/
|
||||||
const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
|
const toTSV = (data: CotizacionBolsa[]) => {
|
||||||
const headerRow = headers.join(';');
|
const dataRows = data.map(row => {
|
||||||
const dataRows = data.map(row =>
|
// Formateamos los datos según los requisitos de redacción
|
||||||
[
|
const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`;
|
||||||
row.ticker,
|
const precio = `$${formatCurrency(row.precioActual)}`;
|
||||||
row.nombreEmpresa,
|
const cambio = `${formatCurrency(row.porcentajeCambio)}%`;
|
||||||
formatCurrency(row.precioActual, 'USD'),
|
|
||||||
formatCurrency(row.cierreAnterior, 'USD'),
|
return [nombreCompleto, precio, cambio].join('\t');
|
||||||
`${row.porcentajeCambio.toFixed(2)}%`,
|
});
|
||||||
formatFullDateTime(row.fechaRegistro)
|
return dataRows.join('\n');
|
||||||
].join(';')
|
|
||||||
);
|
|
||||||
return [headerRow, ...dataRows].join('\n');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs,
|
* Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs,
|
||||||
* diseñado para la página de redacción.
|
* adaptado para las necesidades de redacción.
|
||||||
*/
|
*/
|
||||||
export const RawBolsaUsaTable = () => {
|
export const RawBolsaUsaTable = () => {
|
||||||
// Hooks para obtener los datos y el estado de feriado para el mercado de EEUU.
|
|
||||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
||||||
const isHoliday = useIsHoliday('US');
|
const isHoliday = useIsHoliday('US');
|
||||||
|
|
||||||
|
// Separamos los datos en prioritarios y el resto, manteniendo el orden de la lista
|
||||||
|
const priorityData = data?.filter(d => TICKERS_PRIORITARIOS_USA.includes(d.ticker))
|
||||||
|
.sort((a, b) => TICKERS_PRIORITARIOS_USA.indexOf(a.ticker) - TICKERS_PRIORITARIOS_USA.indexOf(b.ticker));
|
||||||
|
|
||||||
|
const otherData = data?.filter(d => !TICKERS_PRIORITARIOS_USA.includes(d.ticker));
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!data) return;
|
if (!priorityData) return;
|
||||||
const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %", "Fecha de Registro"];
|
const tsvData = toTSV(priorityData);
|
||||||
const csvData = toCSV(headers, data);
|
|
||||||
|
copyToClipboard(tsvData)
|
||||||
copyToClipboard(csvData)
|
.then(() => alert('Datos prioritarios copiados al portapapeles!'))
|
||||||
.then(() => alert('¡Tabla copiada al portapapeles!'))
|
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Error al copiar:', err);
|
console.error('Error al copiar:', err);
|
||||||
alert('Error: No se pudo copiar la tabla.');
|
alert('Error: No se pudo copiar la tabla.');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Estado de carga unificado.
|
|
||||||
const isLoading = dataLoading || isHoliday === null;
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
|
|
||||||
if (isLoading) return <CircularProgress />;
|
if (isLoading) return <CircularProgress />;
|
||||||
if (dataError) return <Alert severity="error">{dataError}</Alert>;
|
if (dataError) return <Alert severity="error">{dataError}</Alert>;
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
if (isHoliday) {
|
if (isHoliday) { return <HolidayAlert />; }
|
||||||
return <HolidayAlert />;
|
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>;
|
||||||
}
|
|
||||||
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU (el fetcher puede estar desactivado).</Alert>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */}
|
{isHoliday && <Box sx={{ mb: 2 }}><HolidayAlert /></Box>}
|
||||||
{isHoliday && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<HolidayAlert />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
Copiar como CSV
|
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
|
||||||
</Button>
|
Copiar Datos Principales
|
||||||
|
</Button>
|
||||||
|
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||||
|
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tabla de Datos Prioritarios */}
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Ticker</TableCell>
|
<TableCell>Símbolo (Nombre)</TableCell>
|
||||||
<TableCell>Nombre</TableCell>
|
<TableCell align="right">Precio Actual</TableCell>
|
||||||
<TableCell align="right">Último Precio</TableCell>
|
<TableCell align="right">% Cambio</TableCell>
|
||||||
<TableCell align="right">Cierre Anterior</TableCell>
|
|
||||||
<TableCell align="right">Variación %</TableCell>
|
|
||||||
<TableCell>Última Act.</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map(row => (
|
{priorityData?.map(row => (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell>{row.ticker}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{row.nombreEmpresa}</TableCell>
|
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
|
||||||
<TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell>
|
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
|
||||||
<TableCell align="right">{formatCurrency(row.cierreAnterior, 'USD')}</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell>
|
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||||
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
|
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|
||||||
|
{/* Sección para Otros Tickers (solo para consulta) */}
|
||||||
|
{otherData && otherData.length > 0 && (
|
||||||
|
<Box mt={4}>
|
||||||
|
<Divider sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="overline">Otros Tickers (Solo Consulta)</Typography>
|
||||||
|
</Divider>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Símbolo (Nombre)</TableCell>
|
||||||
|
<TableCell align="right">Precio Actual</TableCell>
|
||||||
|
<TableCell align="right">% Cambio</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{otherData.map(row => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
|
||||||
|
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||||
|
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,29 +1,32 @@
|
|||||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
|
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography } from '@mui/material';
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
|
||||||
// Importaciones de nuestro proyecto
|
// Importaciones de nuestro proyecto
|
||||||
import { useApiData } from '../../hooks/useApiData';
|
import { useApiData } from '../../hooks/useApiData';
|
||||||
import { useIsHoliday } from '../../hooks/useIsHoliday';
|
import { useIsHoliday } from '../../hooks/useIsHoliday';
|
||||||
import type { CotizacionGrano } from '../../models/mercadoModels';
|
import type { CotizacionGrano } from '../../models/mercadoModels';
|
||||||
import { formatInteger, formatDateOnly, formatFullDateTime } from '../../utils/formatters';
|
import { formatInteger, formatFullDateTime } from '../../utils/formatters';
|
||||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||||
import { HolidayAlert } from '../common/HolidayAlert';
|
import { HolidayAlert } from '../common/HolidayAlert';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Función para convertir los datos de la tabla a formato CSV.
|
* Función para convertir los datos a formato TSV (Tab-Separated Values)
|
||||||
|
* con el formato específico solicitado por redacción.
|
||||||
*/
|
*/
|
||||||
const toCSV = (headers: string[], data: CotizacionGrano[]) => {
|
const toTSV = (data: CotizacionGrano[]) => {
|
||||||
const headerRow = headers.join(';');
|
const dataRows = data.map(row => {
|
||||||
const dataRows = data.map(row =>
|
// Formateamos la variación para que muestre "=" si es cero.
|
||||||
[
|
const variacion = row.variacionPrecio === 0
|
||||||
row.nombre,
|
? '= 0'
|
||||||
formatInteger(row.precio),
|
: formatInteger(row.variacionPrecio);
|
||||||
formatInteger(row.variacionPrecio),
|
|
||||||
formatDateOnly(row.fechaOperacion),
|
const precio = formatInteger(row.precio);
|
||||||
formatFullDateTime(row.fechaRegistro)
|
|
||||||
].join(';')
|
// Unimos los campos con un carácter de tabulación '\t'
|
||||||
);
|
return [row.nombre, precio, variacion].join('\t');
|
||||||
return [headerRow, ...dataRows].join('\n');
|
});
|
||||||
|
|
||||||
|
return dataRows.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,24 +34,21 @@ const toCSV = (headers: string[], data: CotizacionGrano[]) => {
|
|||||||
* diseñado para la página de redacción.
|
* diseñado para la página de redacción.
|
||||||
*/
|
*/
|
||||||
export const RawGranosTable = () => {
|
export const RawGranosTable = () => {
|
||||||
// Hooks para obtener los datos y el estado de feriado para el mercado argentino.
|
|
||||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
||||||
const isHoliday = useIsHoliday('BA');
|
const isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op.", "Fecha de Registro"];
|
const tsvData = toTSV(data);
|
||||||
const csvData = toCSV(headers, data);
|
|
||||||
|
|
||||||
copyToClipboard(csvData)
|
copyToClipboard(tsvData)
|
||||||
.then(() => alert('¡Tabla copiada al portapapeles!'))
|
.then(() => alert('Datos de Granos copiados al portapapeles!'))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Error al copiar:', err);
|
console.error('Error al copiar:', err);
|
||||||
alert('Error: No se pudo copiar la tabla.');
|
alert('Error: No se pudo copiar la tabla.');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Estado de carga unificado.
|
|
||||||
const isLoading = dataLoading || isHoliday === null;
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
|
|
||||||
if (isLoading) return <CircularProgress />;
|
if (isLoading) return <CircularProgress />;
|
||||||
@@ -63,16 +63,21 @@ export const RawGranosTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */}
|
|
||||||
{isHoliday && (
|
{isHoliday && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<HolidayAlert />
|
<HolidayAlert />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
Copiar como CSV
|
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
|
||||||
</Button>
|
Copiar Datos para Redacción
|
||||||
|
</Button>
|
||||||
|
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||||
|
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
@@ -80,8 +85,6 @@ export const RawGranosTable = () => {
|
|||||||
<TableCell>Grano</TableCell>
|
<TableCell>Grano</TableCell>
|
||||||
<TableCell align="right">Precio ($/Tn)</TableCell>
|
<TableCell align="right">Precio ($/Tn)</TableCell>
|
||||||
<TableCell align="right">Variación</TableCell>
|
<TableCell align="right">Variación</TableCell>
|
||||||
<TableCell>Fecha Op.</TableCell>
|
|
||||||
<TableCell>Última Act.</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -89,9 +92,7 @@ export const RawGranosTable = () => {
|
|||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell>{row.nombre}</TableCell>
|
<TableCell>{row.nombre}</TableCell>
|
||||||
<TableCell align="right">${formatInteger(row.precio)}</TableCell>
|
<TableCell align="right">${formatInteger(row.precio)}</TableCell>
|
||||||
<TableCell align="right">{formatInteger(row.variacionPrecio)}</TableCell>
|
<TableCell align="right">{row.variacionPrecio === 0 ? '= 0' : formatInteger(row.variacionPrecio)}</TableCell>
|
||||||
<TableCell>{formatDateOnly(row.fechaOperacion)}</TableCell>
|
|
||||||
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
11
frontend/src/config/priorityTickers.ts
Normal file
11
frontend/src/config/priorityTickers.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export const TICKERS_PRIORITARIOS_LOCAL = [
|
||||||
|
'^MERV', 'GGAL.BA', 'YPFD.BA', 'PAMP.BA', 'BMA.BA',
|
||||||
|
'COME.BA', 'TECO2.BA', 'EDN.BA', 'CRES.BA', 'TXAR.BA',
|
||||||
|
'MIRG.BA', 'CEPU.BA', 'LOMA.BA', 'VALO.BA'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dejaremos las otras listas aquí para los siguientes componentes
|
||||||
|
export const TICKERS_PRIORITARIOS_USA = [
|
||||||
|
'AAPL', 'AMD', 'AMZN', 'BRK-B', 'KO', 'MSFT', 'NVDA',
|
||||||
|
'GLD', 'XLF', 'XLI', 'XLE', 'XLK', 'MELI'
|
||||||
|
];
|
||||||
@@ -38,7 +38,7 @@ namespace Mercados.Api.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var data = await _ganadoRepo.ObtenerUltimaTandaAsync();
|
var data = await _ganadoRepo.ObtenerUltimaTandaDisponibleAsync();
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formatea el nombre del grano para corregir acentos u otros detalles.
|
||||||
|
/// </summary>
|
||||||
|
private string FormatearNombreGrano(string nombreOriginal)
|
||||||
|
{
|
||||||
|
if (nombreOriginal.Equals("Maiz", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Devuelve la versión con el caso de la primera letra original, pero con acento.
|
||||||
|
return char.IsUpper(nombreOriginal[0]) ? "Maíz" : "maíz";
|
||||||
|
}
|
||||||
|
return nombreOriginal; // Devuelve el original si no hay ninguna regla
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(bool Success, string Message)> FetchDataAsync()
|
public async Task<(bool Success, string Message)> FetchDataAsync()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
|
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
|
||||||
@@ -96,7 +109,7 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
{
|
{
|
||||||
cotizaciones.Add(new CotizacionGrano
|
cotizaciones.Add(new CotizacionGrano
|
||||||
{
|
{
|
||||||
Nombre = grain.Key,
|
Nombre = FormatearNombreGrano(grain.Key),
|
||||||
Precio = latestRecord.PrecioCotizacion,
|
Precio = latestRecord.PrecioCotizacion,
|
||||||
VariacionPrecio = latestRecord.VariacionPrecioCotizacion,
|
VariacionPrecio = latestRecord.VariacionPrecioCotizacion,
|
||||||
FechaOperacion = latestRecord.FechaOperacionPizarra,
|
FechaOperacion = latestRecord.FechaOperacionPizarra,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
private readonly ICotizacionGanadoRepository _cotizacionRepository;
|
private readonly ICotizacionGanadoRepository _cotizacionRepository;
|
||||||
private readonly IFuenteDatoRepository _fuenteDatoRepository;
|
private readonly IFuenteDatoRepository _fuenteDatoRepository;
|
||||||
private readonly ILogger<MercadoAgroFetcher> _logger;
|
private readonly ILogger<MercadoAgroFetcher> _logger;
|
||||||
|
private readonly TimeZoneInfo _argentinaTimeZone;
|
||||||
|
|
||||||
public MercadoAgroFetcher(
|
public MercadoAgroFetcher(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -25,40 +26,80 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
_cotizacionRepository = cotizacionRepository;
|
_cotizacionRepository = cotizacionRepository;
|
||||||
_fuenteDatoRepository = fuenteDatoRepository;
|
_fuenteDatoRepository = fuenteDatoRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires");
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(bool Success, string Message)> FetchDataAsync()
|
public async Task<(bool Success, string Message)> FetchDataAsync()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
|
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var htmlContent = await GetHtmlContentAsync();
|
var htmlContent = await GetHtmlContentAsync();
|
||||||
if (string.IsNullOrEmpty(htmlContent))
|
if (string.IsNullOrEmpty(htmlContent)) return (false, "No se pudo obtener el contenido HTML.");
|
||||||
|
|
||||||
|
var cotizacionesNuevas = ParseHtmlToEntities(htmlContent);
|
||||||
|
if (!cotizacionesNuevas.Any())
|
||||||
{
|
{
|
||||||
// Esto sigue siendo un fallo, no se pudo obtener la página
|
return (true, "Conexión exitosa, pero no se encontraron nuevos datos de ganado.");
|
||||||
return (false, "No se pudo obtener el contenido HTML.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cotizaciones = ParseHtmlToEntities(htmlContent);
|
var ahoraEnArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone);
|
||||||
|
var hoy = DateOnly.FromDateTime(ahoraEnArgentina);
|
||||||
|
var cotizacionesViejas = await _cotizacionRepository.ObtenerTandaPorFechaAsync(hoy);
|
||||||
|
|
||||||
if (!cotizaciones.Any())
|
// Si el número de registros es diferente, sabemos que hay cambios.
|
||||||
|
if (cotizacionesViejas.Count() != cotizacionesNuevas.Count)
|
||||||
{
|
{
|
||||||
// La conexión fue exitosa, pero no se encontraron datos válidos.
|
_logger.LogInformation("El número de registros de {SourceName} ha cambiado. Actualizando...", SourceName);
|
||||||
// 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);
|
else
|
||||||
return (true, "Conexión exitosa, pero no se encontraron nuevos datos.");
|
{
|
||||||
|
// Si el número de registros es el mismo, comparamos el contenido.
|
||||||
|
// Convertimos los nuevos a un diccionario para una búsqueda rápida por clave.
|
||||||
|
var nuevasDict = cotizacionesNuevas.ToDictionary(c => $"{c.Categoria}|{c.Especificaciones}");
|
||||||
|
|
||||||
|
bool hayCambios = false;
|
||||||
|
foreach (var cotizacionVieja in cotizacionesViejas)
|
||||||
|
{
|
||||||
|
var clave = $"{cotizacionVieja.Categoria}|{cotizacionVieja.Especificaciones}";
|
||||||
|
|
||||||
|
// Buscamos si el registro viejo existe en los nuevos y comparamos los valores.
|
||||||
|
if (!nuevasDict.TryGetValue(clave, out var cotizacionNueva) ||
|
||||||
|
cotizacionVieja.Maximo != cotizacionNueva.Maximo ||
|
||||||
|
cotizacionVieja.Minimo != cotizacionNueva.Minimo ||
|
||||||
|
cotizacionVieja.Cabezas != cotizacionNueva.Cabezas ||
|
||||||
|
cotizacionVieja.KilosTotales != cotizacionNueva.KilosTotales)
|
||||||
|
{
|
||||||
|
hayCambios = true;
|
||||||
|
_logger.LogInformation("Se detectó un cambio en la categoría: {Categoria}", clave);
|
||||||
|
break; // Encontramos un cambio, no necesitamos seguir buscando.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hayCambios)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No se encontraron cambios en los datos de {SourceName}. No se requiere actualización.", SourceName);
|
||||||
|
return (true, "Datos sin cambios.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _cotizacionRepository.GuardarMuchosAsync(cotizaciones);
|
_logger.LogInformation("Se detectaron cambios en los datos de {SourceName}. Reemplazando la tanda del día...", SourceName);
|
||||||
|
await _cotizacionRepository.ReemplazarTandaDelDiaAsync(hoy, cotizacionesNuevas);
|
||||||
|
|
||||||
await UpdateSourceInfoAsync();
|
await UpdateSourceInfoAsync();
|
||||||
|
|
||||||
_logger.LogInformation("Fetch para {SourceName} completado exitosamente. Se guardaron {Count} registros.", SourceName, cotizaciones.Count);
|
_logger.LogInformation("Fetch para {SourceName} completado. Se actualizaron {Count} registros.", SourceName, cotizacionesNuevas.Count);
|
||||||
return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros.");
|
return (true, $"Proceso completado. Se actualizaron {cotizacionesNuevas.Count} registros.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Un catch aquí sí es un error real (ej. 404, timeout, etc.)
|
|
||||||
_logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName);
|
_logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName);
|
||||||
return (false, $"Error: {ex.Message}");
|
return (false, $"Error: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,59 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
|||||||
_connectionFactory = connectionFactory;
|
_connectionFactory = connectionFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync()
|
||||||
|
{
|
||||||
|
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||||
|
// Esta consulta busca la fecha más reciente que tiene registros
|
||||||
|
// y luego devuelve todos los registros de esa fecha. Es la lógica original y correcta.
|
||||||
|
const string sql = @"
|
||||||
|
SELECT * FROM CotizacionesGanado
|
||||||
|
WHERE CONVERT(date, FechaRegistro) = (
|
||||||
|
SELECT TOP 1 CONVERT(date, FechaRegistro)
|
||||||
|
FROM CotizacionesGanado
|
||||||
|
ORDER BY FechaRegistro DESC
|
||||||
|
);";
|
||||||
|
return await connection.QueryAsync<CotizacionGanado>(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Este método lo sigue necesitando el Fetcher para comparar los datos del día.
|
||||||
|
public async Task<IEnumerable<CotizacionGanado>> ObtenerTandaPorFechaAsync(DateOnly fecha)
|
||||||
|
{
|
||||||
|
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||||
|
const string sql = @"
|
||||||
|
SELECT * FROM CotizacionesGanado
|
||||||
|
WHERE CONVERT(date, FechaRegistro) = @Fecha;";
|
||||||
|
return await connection.QueryAsync<CotizacionGanado>(sql, new { Fecha = fecha.ToString("yyyy-MM-dd") });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nuevo método para hacer un "reemplazo" inteligente
|
||||||
|
public async Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones)
|
||||||
|
{
|
||||||
|
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||||
|
connection.Open();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Borramos solo los registros del día que vamos a actualizar
|
||||||
|
const string deleteSql = "DELETE FROM CotizacionesGanado WHERE CONVERT(date, FechaRegistro) = @Fecha;";
|
||||||
|
await connection.ExecuteAsync(deleteSql, new { Fecha = fecha.ToString("yyyy-MM-dd") }, transaction);
|
||||||
|
|
||||||
|
// Insertamos la nueva tanda completa
|
||||||
|
const string insertSql = @"
|
||||||
|
INSERT INTO CotizacionesGanado (Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano, Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro)
|
||||||
|
VALUES (@Categoria, @Especificaciones, @Maximo, @Minimo, @Promedio, @Mediano, @Cabezas, @KilosTotales, @KilosPorCabeza, @ImporteTotal, @FechaRegistro);";
|
||||||
|
await connection.ExecuteAsync(insertSql, nuevasCotizaciones, transaction);
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
transaction.Rollback();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones)
|
public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones)
|
||||||
{
|
{
|
||||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||||
@@ -30,19 +83,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
|||||||
|
|
||||||
await connection.ExecuteAsync(sql, cotizaciones);
|
await connection.ExecuteAsync(sql, cotizaciones);
|
||||||
}
|
}
|
||||||
public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync()
|
|
||||||
{
|
|
||||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
|
||||||
|
|
||||||
// Primero, obtenemos la fecha de registro más reciente.
|
|
||||||
// Luego, seleccionamos todos los registros que tengan esa fecha.
|
|
||||||
const string sql = @"
|
|
||||||
SELECT *
|
|
||||||
FROM CotizacionesGanado
|
|
||||||
WHERE FechaRegistro = (SELECT MAX(FechaRegistro) FROM CotizacionesGanado);";
|
|
||||||
|
|
||||||
return await connection.QueryAsync<CotizacionGanado>(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias)
|
public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias)
|
||||||
{
|
{
|
||||||
@@ -60,10 +100,11 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
|||||||
ORDER BY
|
ORDER BY
|
||||||
FechaRegistro ASC;";
|
FechaRegistro ASC;";
|
||||||
|
|
||||||
return await connection.QueryAsync<CotizacionGanado>(sql, new {
|
return await connection.QueryAsync<CotizacionGanado>(sql, new
|
||||||
Categoria = categoria,
|
{
|
||||||
Especificaciones = especificaciones,
|
Categoria = categoria,
|
||||||
Dias = dias
|
Especificaciones = especificaciones,
|
||||||
|
Dias = dias
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
|||||||
{
|
{
|
||||||
public interface ICotizacionGanadoRepository : IBaseRepository
|
public interface ICotizacionGanadoRepository : IBaseRepository
|
||||||
{
|
{
|
||||||
Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones);
|
Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync();
|
||||||
Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync();
|
Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones);
|
||||||
Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias);
|
Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias);
|
||||||
|
Task<IEnumerable<CotizacionGanado>> ObtenerTandaPorFechaAsync(DateOnly fecha);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Debug",
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"DefaultConnection": ""
|
"DefaultConnection": ""
|
||||||
},
|
},
|
||||||
"Schedules": {
|
"Schedules": {
|
||||||
"MercadoAgroganadero": "0 11 * * 1-5",
|
"MercadoAgroganadero": "0 11,15,18,21 * * 1-5",
|
||||||
"BCR": "30 11 * * 1-5",
|
"BCR": "30 11 * * 1-5",
|
||||||
"Bolsas": "10 11-17 * * 1-5",
|
"Bolsas": "10 11-17 * * 1-5",
|
||||||
"Holidays": "0 2 * * 1"
|
"Holidays": "0 2 * * 1"
|
||||||
|
|||||||
Reference in New Issue
Block a user