Improve the dahboard with the weather info

This commit is contained in:
Urtzi Alfaro
2025-08-18 13:36:37 +02:00
parent 355c0080cc
commit afca94dadd
12 changed files with 747 additions and 302 deletions

View File

@@ -67,18 +67,8 @@ export class ExternalService {
return response; return response;
} catch (error) { } catch (error) {
console.error('Failed to fetch weather from backend:', error); console.error('Failed to fetch weather from AEMET API via backend:', error);
throw new Error(`Weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
// Fallback weather for Madrid (matching WeatherData schema)
return {
date: new Date().toISOString(),
temperature: 18,
description: 'Parcialmente nublado',
precipitation: 0,
humidity: 65,
wind_speed: 10,
source: 'fallback'
};
} }
} }
@@ -109,8 +99,8 @@ export class ExternalService {
return []; return [];
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch weather forecast:', error); console.error('Failed to fetch weather forecast from AEMET API:', error);
return []; throw new Error(`Weather forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
} }
} }
@@ -136,8 +126,8 @@ export class ExternalService {
// Return backend response directly (matches WeatherData interface) // Return backend response directly (matches WeatherData interface)
return Array.isArray(response) ? response : response.data || []; return Array.isArray(response) ? response : response.data || [];
} catch (error) { } catch (error) {
console.error('Failed to fetch historical weather:', error); console.error('Failed to fetch historical weather from AEMET API:', error);
return []; throw new Error(`Historical weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
} }
} }
@@ -160,17 +150,8 @@ export class ExternalService {
// Return backend response directly (matches TrafficData interface) // Return backend response directly (matches TrafficData interface)
return response; return response;
} catch (error) { } catch (error) {
console.error('Failed to fetch traffic data:', error); console.error('Failed to fetch traffic data from external API:', error);
throw new Error(`Traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
// Fallback traffic data (matching TrafficData schema)
return {
date: new Date().toISOString(),
traffic_volume: 50,
pedestrian_count: 25,
congestion_level: 'medium',
average_speed: 30,
source: 'fallback'
};
} }
} }
@@ -194,8 +175,8 @@ export class ExternalService {
// Return backend response directly (matches TrafficData interface) // Return backend response directly (matches TrafficData interface)
return Array.isArray(response) ? response : response.data || []; return Array.isArray(response) ? response : response.data || [];
} catch (error) { } catch (error) {
console.error('Failed to fetch traffic forecast:', error); console.error('Failed to fetch traffic forecast from external API:', error);
return []; throw new Error(`Traffic forecast unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
} }
} }
@@ -221,8 +202,8 @@ export class ExternalService {
// Return backend response directly (matches TrafficData interface) // Return backend response directly (matches TrafficData interface)
return Array.isArray(response) ? response : response.data || []; return Array.isArray(response) ? response : response.data || [];
} catch (error) { } catch (error) {
console.error('Failed to fetch historical traffic:', error); console.error('Failed to fetch historical traffic from external API:', error);
return []; throw new Error(`Historical traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
} }
} }
@@ -241,11 +222,12 @@ export class ExternalService {
}; };
try { try {
// Test weather service // Test weather service (AEMET API)
await this.getCurrentWeather(tenantId, 40.4168, -3.7038); // Madrid coordinates await this.getCurrentWeather(tenantId, 40.4168, -3.7038); // Madrid coordinates
results.weather = true; results.weather = true;
} catch (error) { } catch (error) {
console.warn('Weather service connectivity test failed:', error); console.warn('AEMET weather service connectivity test failed:', error);
results.weather = false;
} }
try { try {
@@ -254,6 +236,7 @@ export class ExternalService {
results.traffic = true; results.traffic = true;
} catch (error) { } catch (error) {
console.warn('Traffic service connectivity test failed:', error); console.warn('Traffic service connectivity test failed:', error);
results.traffic = false;
} }
results.overall = results.weather && results.traffic; results.overall = results.weather && results.traffic;

View File

@@ -54,8 +54,18 @@ const OperationsLayout: React.FC = () => {
icon: 'ShoppingCart', icon: 'ShoppingCart',
children: [ children: [
{ id: 'daily-sales', label: 'Ventas Diarias', href: '/app/operations/sales/daily-sales' }, { id: 'daily-sales', label: 'Ventas Diarias', href: '/app/operations/sales/daily-sales' },
{ id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' }, { id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' }
{ id: 'pos-integration', label: bakeryType === 'individual' ? 'TPV' : 'Multi-TPV', href: '/app/operations/sales/pos-integration' } ]
},
{
id: 'pos',
label: bakeryType === 'individual' ? 'TPV' : 'Sistema TPV',
href: '/app/operations/pos',
icon: 'CreditCard',
children: [
{ id: 'integrations', label: 'Integraciones', href: '/app/operations/pos/integrations' },
{ id: 'sync-status', label: 'Estado Sincronización', href: '/app/operations/pos/sync-status' },
{ id: 'transactions', label: 'Transacciones', href: '/app/operations/pos/transactions' }
] ]
} }
]; ];

View File

@@ -6,7 +6,7 @@ import {
Globe, Globe,
Key, Key,
Webhook, Webhook,
Sync, RefreshCw,
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
Clock, Clock,
@@ -481,7 +481,7 @@ const POSConfigurationForm: React.FC<POSConfigurationFormProps> = ({
{/* Sync Configuration */} {/* Sync Configuration */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 flex items-center"> <h3 className="text-lg font-medium text-gray-900 flex items-center">
<Sync className="h-5 w-5 mr-2" /> <RefreshCw className="h-5 w-5 mr-2" />
Synchronization Settings Synchronization Settings
</h3> </h3>

View File

@@ -0,0 +1,417 @@
import React, { useEffect, useState } from 'react';
import {
Cloud,
CloudRain,
Sun,
Thermometer,
Droplets,
Wind,
Gauge,
Users,
TrendingUp,
AlertTriangle,
CheckCircle,
Clock
} from 'lucide-react';
import { useExternal } from '../../api';
import { useTenantId } from '../../hooks/useTenantId';
import type { WeatherData, TrafficData, WeatherForecast } from '../../api/services/external.service';
import Card from '../ui/Card';
interface WeatherContextProps {
className?: string;
}
const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
const { tenantId } = useTenantId();
const {
getCurrentWeather,
getCurrentTraffic,
getWeatherForecast,
isLoading,
error
} = useExternal();
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// Madrid coordinates (default for bakery location)
const latitude = 40.4168;
const longitude = -3.7038;
const loadWeatherContext = async () => {
if (!tenantId) return;
setIsRefreshing(true);
try {
// Load current weather data from AEMET
const [weather, traffic, forecast] = await Promise.allSettled([
getCurrentWeather(tenantId, latitude, longitude),
getCurrentTraffic(tenantId, latitude, longitude),
getWeatherForecast(tenantId, latitude, longitude, 3)
]);
if (weather.status === 'fulfilled') {
setWeatherData(weather.value);
} else {
console.warn('Weather data unavailable:', weather.reason);
setWeatherData(null);
}
if (traffic.status === 'fulfilled') {
setTrafficData(traffic.value);
} else {
console.warn('Traffic data unavailable:', traffic.reason);
setTrafficData(null);
}
if (forecast.status === 'fulfilled') {
setWeatherForecast(forecast.value);
} else {
console.warn('Weather forecast unavailable:', forecast.reason);
setWeatherForecast([]);
}
setLastUpdated(new Date());
} catch (error) {
console.error('Failed to load weather context:', error);
} finally {
setIsRefreshing(false);
}
};
useEffect(() => {
loadWeatherContext();
// Refresh data every 15 minutes
const interval = setInterval(loadWeatherContext, 15 * 60 * 1000);
return () => clearInterval(interval);
}, [tenantId]);
const getWeatherIcon = (description: string = '', precipitation: number = 0) => {
if (precipitation > 0) return <CloudRain className="h-6 w-6 text-blue-500" />;
if (description.toLowerCase().includes('nublado')) return <Cloud className="h-6 w-6 text-gray-500" />;
return <Sun className="h-6 w-6 text-yellow-500" />;
};
const getTrafficImpact = () => {
if (!trafficData) return null;
const { traffic_volume = 50, pedestrian_count = 25, congestion_level = 'medium' } = trafficData;
let impactColor = 'text-green-600';
let impactText = 'Favorable para las ventas';
let impactIcon = <CheckCircle className="h-4 w-4 text-green-500" />;
if (congestion_level === 'high' || traffic_volume > 80) {
impactColor = 'text-red-600';
impactText = 'Tráfico alto - posible reducción clientes';
impactIcon = <AlertTriangle className="h-4 w-4 text-red-500" />;
} else if (congestion_level === 'low' || traffic_volume < 30) {
impactColor = 'text-yellow-600';
impactText = 'Tráfico bajo - ventas pueden reducirse';
impactIcon = <AlertTriangle className="h-4 w-4 text-yellow-500" />;
}
return { impactColor, impactText, impactIcon, traffic_volume, pedestrian_count };
};
const getWeatherImpact = () => {
if (!weatherData) return null;
const impacts = [];
if (weatherData.precipitation && weatherData.precipitation > 0) {
if (weatherData.precipitation > 10) {
impacts.push('☔ Lluvia fuerte: -25% clientes esperados');
} else {
impacts.push('🌦️ Lluvia ligera: -15% clientes esperados');
}
}
if (weatherData.temperature && weatherData.temperature < 5) {
impacts.push('🥶 Frío intenso: +20% bebidas calientes');
} else if (weatherData.temperature && weatherData.temperature > 25) {
impacts.push('☀️ Tiempo cálido: +15% bebidas frías');
}
if (weatherData.wind_speed && weatherData.wind_speed > 30) {
impacts.push('💨 Viento fuerte: posible reducción tránsito');
}
return impacts;
};
if (isLoading && !weatherData) {
return (
<Card className={`p-6 ${className}`}>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Cargando datos de AEMET...</span>
</div>
</Card>
);
}
if (error && !weatherData) {
return (
<Card className={`p-6 border-red-200 bg-red-50 ${className}`}>
<div className="text-center">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-red-800 mb-2">Datos Meteorológicos No Disponibles</h3>
<p className="text-red-700 text-sm mb-4">
No se pueden obtener datos de AEMET en este momento. Los pronósticos pueden verse afectados.
</p>
<button
onClick={loadWeatherContext}
disabled={isRefreshing}
className="px-4 py-2 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg transition-colors disabled:opacity-50"
>
{isRefreshing ? 'Reintentando...' : 'Reintentar'}
</button>
</div>
</Card>
);
}
// Warning for synthetic data
if (weatherData && weatherData.source === 'synthetic') {
return (
<Card className={`p-6 border-yellow-200 bg-yellow-50 ${className}`}>
<div className="text-center">
<AlertTriangle className="h-12 w-12 text-yellow-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-yellow-800 mb-2"> Usando Datos de Prueba</h3>
<p className="text-yellow-700 text-sm mb-4">
El sistema está usando datos meteorológicos sintéticos. Para obtener predicciones precisas,
es necesario configurar la integración con AEMET.
</p>
<div className="text-xs text-yellow-600 mb-4">
<strong>Datos actuales (sintéticos):</strong><br/>
Temperatura: {weatherData.temperature}°C |
Humedad: {weatherData.humidity}% |
Viento: {weatherData.wind_speed} km/h
</div>
<button
onClick={loadWeatherContext}
disabled={isRefreshing}
className="px-4 py-2 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 rounded-lg transition-colors disabled:opacity-50"
>
{isRefreshing ? 'Verificando AEMET...' : 'Verificar Conexión AEMET'}
</button>
</div>
</Card>
);
}
const trafficImpact = getTrafficImpact();
const weatherImpacts = getWeatherImpact();
return (
<Card className={`p-6 border-l-4 border-l-blue-500 ${className}`}>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900 flex items-center">
{weatherData && getWeatherIcon(weatherData.description, weatherData.precipitation)}
<span className="ml-3">Clima & Contexto Operacional</span>
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
{weatherData?.source === 'aemet' ? 'AEMET Oficial' : weatherData?.source || 'Fuente Externa'}
</span>
</h3>
<div className="flex items-center text-xs text-gray-500">
<Clock className="h-3 w-3 mr-1" />
{lastUpdated ? `${lastUpdated.toLocaleTimeString()}` : 'No actualizado'}
{isRefreshing && (
<div className="ml-2 animate-spin rounded-full h-3 w-3 border-b border-gray-400"></div>
)}
</div>
</div>
{/* Current Weather Section */}
{weatherData && (
<div className="mb-6">
<h4 className="font-medium text-gray-800 mb-3">Condiciones Actuales (AEMET)</h4>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-blue-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Thermometer className="h-4 w-4 text-blue-600" />
<span className="text-xs text-blue-600">Temperatura</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-blue-900">
{weatherData.temperature?.toFixed(1) || '--'}°C
</span>
</div>
</div>
<div className="bg-cyan-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Droplets className="h-4 w-4 text-cyan-600" />
<span className="text-xs text-cyan-600">Humedad</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-cyan-900">
{weatherData.humidity?.toFixed(0) || '--'}%
</span>
</div>
</div>
<div className="bg-green-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Wind className="h-4 w-4 text-green-600" />
<span className="text-xs text-green-600">Viento</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-green-900">
{weatherData.wind_speed?.toFixed(0) || '--'} km/h
</span>
</div>
</div>
<div className="bg-purple-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Gauge className="h-4 w-4 text-purple-600" />
<span className="text-xs text-purple-600">Presión</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-purple-900">
{weatherData.pressure?.toFixed(0) || '--'} hPa
</span>
</div>
</div>
</div>
{weatherData.description && (
<div className="mt-3 text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
<strong>Descripción:</strong> {weatherData.description}
</div>
)}
{weatherData.precipitation && weatherData.precipitation > 0 && (
<div className="mt-2 text-sm text-blue-600 bg-blue-50 rounded-lg p-3">
<strong>Precipitación:</strong> {weatherData.precipitation.toFixed(1)} mm
</div>
)}
</div>
)}
{/* Traffic Context Section */}
{trafficData && trafficImpact && (
<div className="mb-6">
<h4 className="font-medium text-gray-800 mb-3">Contexto de Tráfico</h4>
<div className="grid grid-cols-2 gap-4 mb-3">
<div className="bg-orange-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<TrendingUp className="h-4 w-4 text-orange-600" />
<span className="text-xs text-orange-600">Volumen</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-orange-900">
{trafficImpact.traffic_volume}%
</span>
</div>
</div>
<div className="bg-indigo-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Users className="h-4 w-4 text-indigo-600" />
<span className="text-xs text-indigo-600">Peatones</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-indigo-900">
{trafficImpact.pedestrian_count}
</span>
</div>
</div>
</div>
<div className={`flex items-center text-sm ${trafficImpact.impactColor} bg-gray-50 rounded-lg p-3`}>
{trafficImpact.impactIcon}
<span className="ml-2">{trafficImpact.impactText}</span>
</div>
</div>
)}
{/* Business Impact Analysis - Most Important Section */}
{weatherImpacts && weatherImpacts.length > 0 && (
<div className="mb-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="font-semibold text-amber-800 mb-3 flex items-center">
<TrendingUp className="h-5 w-5 mr-2" />
Impacto Operacional Detectado
</h4>
<div className="grid grid-cols-1 gap-3">
{weatherImpacts.map((impact, index) => (
<div key={index} className="flex items-center text-sm font-medium text-amber-700 bg-amber-100 rounded-lg p-3">
<span className="text-lg mr-3">{impact.split(' ')[0]}</span>
<span>{impact.substring(impact.indexOf(' ') + 1)}</span>
</div>
))}
</div>
<div className="mt-3 text-xs text-amber-600 flex items-center">
<CheckCircle className="h-3 w-3 mr-1" />
Recomendaciones aplicadas automáticamente en pronósticos de demanda
</div>
</div>
)}
{/* Weather Forecast */}
{weatherForecast.length > 0 && (
<div>
<h4 className="font-medium text-gray-800 mb-3">Pronóstico (Próximos Días)</h4>
<div className="space-y-2">
{weatherForecast.slice(0, 3).map((forecast, index) => (
<div key={index} className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
<div className="flex items-center">
{getWeatherIcon(forecast.description, forecast.precipitation)}
<div className="ml-3">
<div className="text-sm font-medium text-gray-900">
{new Date(forecast.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })}
</div>
<div className="text-xs text-gray-600">{forecast.description}</div>
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
{forecast.temperature_max}° / {forecast.temperature_min}°
</div>
{forecast.precipitation > 0 && (
<div className="text-xs text-blue-600">{forecast.precipitation}mm</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Data Source Info & Controls */}
<div className="mt-4 pt-4 border-t border-gray-200 bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center space-x-4">
<span className="text-gray-600">
<strong>Fuente:</strong> {weatherData?.source === 'aemet' ? '🇪🇸 AEMET (Agencia Estatal de Meteorología)' :
weatherData?.source === 'synthetic' ? '⚠️ Datos de Prueba' :
weatherData?.source || 'No disponible'}
</span>
{weatherData?.date && (
<span className="text-gray-500">
<strong>Fecha datos:</strong> {new Date(weatherData.date).toLocaleString('es-ES')}
</span>
)}
</div>
<button
onClick={loadWeatherContext}
disabled={isRefreshing}
className="px-3 py-1 text-xs bg-blue-100 text-blue-700 hover:bg-blue-200 rounded transition-colors disabled:opacity-50"
>
{isRefreshing ? '⟳ Actualizando...' : '↻ Actualizar'}
</button>
</div>
</div>
</Card>
);
};
export default WeatherContext;

View File

@@ -0,0 +1,7 @@
export { default as CriticalAlerts } from './CriticalAlerts';
export { default as OrderSuggestions } from './OrderSuggestions';
export { default as QuickActions } from './QuickActions';
export { default as QuickOverview } from './QuickOverview';
export { default as TodayProduction } from './TodayProduction';
export { default as TodayRevenue } from './TodayRevenue';
export { default as WeatherContext } from './WeatherContext';

View File

@@ -9,9 +9,14 @@ import { useTenantId } from './useTenantId';
interface DashboardData { interface DashboardData {
weather: { weather: {
temperature: number; temperature?: number;
description: string; description?: string;
precipitation: number; precipitation?: number;
humidity?: number;
wind_speed?: number;
pressure?: number;
source: string;
date: string;
} | null; } | null;
todayForecasts: Array<{ todayForecasts: Array<{
product: string; product: string;
@@ -91,18 +96,16 @@ export const useDashboard = () => {
try { try {
// 1. Get available products from inventory service // 1. Get available products from inventory service
const products = await getProductsList(tenantId); const products = await getProductsList(tenantId);
// 2. Get weather data (Madrid coordinates) // 2. Get complete weather data from AEMET (Madrid coordinates)
let weather = null; let weather = null;
try { try {
weather = await getCurrentWeather(tenantId, 40.4168, -3.7038); const weatherResponse = await getCurrentWeather(tenantId, 40.4168, -3.7038);
// Use the full WeatherData interface instead of limited subset
weather = weatherResponse;
} catch (error) { } catch (error) {
console.warn('Failed to fetch weather:', error); console.error('AEMET weather data unavailable for dashboard:', error);
// Fallback weather // No fallback - weather remains null if AEMET API fails
weather = { weather = null;
temperature: 18,
description: 'Parcialmente nublado',
precipitation: 0
};
} }
// 3. Generate forecasts for each product // 3. Generate forecasts for each product

View File

@@ -1,6 +1,6 @@
// Real API hook for Order Suggestions using backend data // Real API hook for Order Suggestions using backend data
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useSales, useExternal, useForecast, useInventoryProducts } from '../api'; import { useExternal, useForecast, useInventoryProducts } from '../api';
import { useTenantId } from './useTenantId'; import { useTenantId } from './useTenantId';
import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions'; import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions';
@@ -44,10 +44,6 @@ export const useOrderSuggestions = (providedTenantId?: string | null) => {
const tenantId = providedTenantId !== undefined ? providedTenantId : hookTenantId; const tenantId = providedTenantId !== undefined ? providedTenantId : hookTenantId;
console.log('🏢 OrderSuggestions: Tenant info:', { tenantId, tenantLoading, tenantError }); console.log('🏢 OrderSuggestions: Tenant info:', { tenantId, tenantLoading, tenantError });
const {
getSalesAnalytics,
getDashboardStats
} = useSales();
const { const {
getProductsList getProductsList
} = useInventoryProducts(); } = useInventoryProducts();
@@ -55,159 +51,213 @@ export const useOrderSuggestions = (providedTenantId?: string | null) => {
getCurrentWeather getCurrentWeather
} = useExternal(); } = useExternal();
const { const {
createSingleForecast, createSingleForecast
getQuickForecasts,
getForecastAlerts
} = useForecast(); } = useForecast();
// Generate daily order suggestions based on real forecast data // Generate daily order suggestions - creates next day forecasts for all products
const generateDailyOrderSuggestions = useCallback(async (): Promise<DailyOrderItem[]> => { const generateDailyOrderSuggestions = useCallback(async (): Promise<DailyOrderItem[]> => {
if (!tenantId) return []; if (!tenantId) return [];
try { try {
console.log('📊 OrderSuggestions: Generating daily suggestions for tenant:', tenantId); console.log('📊 OrderSuggestions: Generating daily suggestions for tenant:', tenantId);
// Get products list from backend // Get all products from backend
const productsList = await getProductsList(tenantId); const productsList = await getProductsList(tenantId);
const products = productsList.map(p => p.name); console.log('📋 OrderSuggestions: Products list:', productsList);
console.log('📋 OrderSuggestions: Products list:', products);
// Filter for daily bakery products (case insensitive) // Filter for daily products (bakery items that need daily forecasting)
const dailyProductKeywords = ['pan', 'baguette', 'croissant', 'magdalena']; const dailyProductKeywords = ['pan', 'baguette', 'croissant', 'magdalena', 'tarta', 'bollo', 'empanada'];
const dailyProducts = products.filter(p => const dailyProducts = productsList.filter(p =>
dailyProductKeywords.some(keyword => dailyProductKeywords.some(keyword =>
p.toLowerCase().includes(keyword.toLowerCase()) p.name.toLowerCase().includes(keyword.toLowerCase())
) )
); );
console.log('🥖 OrderSuggestions: Daily products:', dailyProducts); console.log('🥖 OrderSuggestions: Daily products:', dailyProducts);
// Get quick forecasts for these products // Create individual forecasts for each daily product for next day
const quickForecasts = await getQuickForecasts(tenantId);
console.log('🔮 OrderSuggestions: Quick forecasts:', quickForecasts);
// Get weather data to determine urgency
const weather = await getCurrentWeather(tenantId, 40.4168, -3.7038);
console.log('🌤️ OrderSuggestions: Weather data:', weather);
const suggestions: DailyOrderItem[] = []; const suggestions: DailyOrderItem[] = [];
console.log('🔄 OrderSuggestions: Processing daily products:', dailyProducts); const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Get weather data to determine urgency factors
let weather = null;
try {
weather = await getCurrentWeather(tenantId, 40.4168, -3.7038);
console.log('🌤️ OrderSuggestions: Weather data:', weather);
} catch (error) {
console.warn('⚠️ OrderSuggestions: AEMET weather data unavailable, continuing without weather factors:', error);
weather = null;
}
for (const product of dailyProducts) { for (const product of dailyProducts) {
console.log('🔄 OrderSuggestions: Processing product:', product); console.log('🔄 OrderSuggestions: Creating forecast for product:', product.name);
// Find forecast for this product
const forecast = quickForecasts.find(f =>
f.product_name === product || f.inventory_product_id === product
);
if (forecast) { try {
// Calculate suggested quantity based on prediction // Create single forecast for next day for this specific product
const suggestedQuantity = Math.max(forecast.next_day_prediction, 10); const forecastRequest = {
inventory_product_id: product.inventory_product_id,
// Determine urgency based on confidence and trend forecast_date: tomorrow,
let urgency: 'high' | 'medium' | 'low' = 'medium'; forecast_days: 1,
if (forecast.confidence_score > 0.9 && forecast.trend_direction === 'up') { location: 'Madrid',
urgency = 'high'; include_external_factors: true,
} else if (forecast.confidence_score < 0.7) { confidence_intervals: true
urgency = 'low';
}
// Generate reason based on forecast data
let reason = `Predicción: ${forecast.next_day_prediction} unidades`;
if (forecast.trend_direction === 'up') {
reason += ' (tendencia al alza)';
}
if (weather && weather.precipitation > 0) {
reason += ', lluvia prevista';
urgency = urgency === 'low' ? 'medium' : 'high';
}
const orderItem: DailyOrderItem = {
id: `daily-${product.toLowerCase().replace(/\s+/g, '-')}`,
product,
emoji: getProductEmoji(product),
suggestedQuantity: Math.round(suggestedQuantity),
currentQuantity: Math.round(suggestedQuantity * 0.2), // Assume 20% current stock
unit: 'unidades',
urgency,
reason,
confidence: Math.round(forecast.confidence_score * 100),
supplier: SUPPLIERS[product] || 'Proveedor General',
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 2.5) * 100) / 100,
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
}; };
const forecasts = await createSingleForecast(tenantId, forecastRequest);
if (forecasts.length > 0) {
const forecast = forecasts[0];
const suggestedQuantity = Math.max(Math.round(forecast.predicted_demand), 5);
// Determine urgency based on confidence and external factors
let urgency: 'high' | 'medium' | 'low' = 'medium';
const confidence = forecast.confidence_level || 0.8;
if (confidence > 0.9) {
urgency = 'low'; // High confidence = low urgency
} else if (confidence < 0.6) {
urgency = 'high'; // Low confidence = high urgency, need more buffer
}
// Adjust based on weather
if (weather && weather.precipitation > 0) {
urgency = urgency === 'low' ? 'medium' : 'high';
}
suggestions.push(orderItem); // Generate reason based on forecast data
console.log(' OrderSuggestions: Added daily suggestion:', orderItem); let reason = `Predicción: ${suggestedQuantity} unidades (demanda para mañana)`;
if (weather && weather.precipitation > 0) {
reason += ', lluvia prevista';
}
const orderItem: DailyOrderItem = {
id: `daily-${product.name.toLowerCase().replace(/\s+/g, '-')}`,
product: product.name,
emoji: getProductEmoji(product.name),
suggestedQuantity,
currentQuantity: Math.round(suggestedQuantity * (0.1 + Math.random() * 0.3)), // Random current stock 10-40%
unit: getProductUnit(product.name),
urgency,
reason,
confidence: Math.round(confidence * 100),
supplier: SUPPLIERS[product.name] || 'Proveedor General',
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product.name] || 2.5) * 100) / 100,
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
};
suggestions.push(orderItem);
console.log(' OrderSuggestions: Added daily suggestion:', orderItem);
}
} catch (error) {
console.warn(`⚠️ OrderSuggestions: Failed to create forecast for ${product.name}:`, error);
// Continue with other products even if one fails
} }
} }
console.log('🎯 OrderSuggestions: Final daily suggestions:', suggestions); console.log('🎯 OrderSuggestions: Final daily suggestions:', suggestions);
return suggestions; return suggestions.sort((a, b) => {
// Sort by urgency (high first) then by suggested quantity
const urgencyOrder = { high: 3, medium: 2, low: 1 };
if (urgencyOrder[a.urgency] !== urgencyOrder[b.urgency]) {
return urgencyOrder[b.urgency] - urgencyOrder[a.urgency];
}
return b.suggestedQuantity - a.suggestedQuantity;
});
} catch (error) { } catch (error) {
console.error('❌ OrderSuggestions: Error in generateDailyOrderSuggestions:', error); console.error('❌ OrderSuggestions: Error in generateDailyOrderSuggestions:', error);
return []; return [];
} }
}, [tenantId, getProductsList, getQuickForecasts, getCurrentWeather]); }, [tenantId, getProductsList, createSingleForecast, getCurrentWeather]);
// Generate weekly order suggestions based on sales analytics // Generate weekly order suggestions - creates 7-day forecasts for weekly order products
const generateWeeklyOrderSuggestions = useCallback(async (): Promise<WeeklyOrderItem[]> => { const generateWeeklyOrderSuggestions = useCallback(async (): Promise<WeeklyOrderItem[]> => {
if (!tenantId) return []; if (!tenantId) return [];
console.log('📊 OrderSuggestions: Generating weekly suggestions for tenant:', tenantId); try {
console.log('📊 OrderSuggestions: Generating weekly suggestions for tenant:', tenantId);
// Get sales analytics for the past month // Get all products from backend
const endDate = new Date().toISOString(); const productsList = await getProductsList(tenantId);
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); console.log('📋 OrderSuggestions: Products list for weekly:', productsList);
const analytics = await getSalesAnalytics(tenantId, startDate, endDate); // Filter for weekly products (ingredients, supplies, drinks that need weekly forecasting)
console.log('📈 OrderSuggestions: Sales analytics:', analytics); const weeklyProductKeywords = ['café', 'leche', 'mantequilla', 'vaso', 'servilleta', 'bolsa', 'bebida', 'refresco', 'agua'];
const weeklyProducts = productsList.filter(p =>
// Weekly products (ingredients and supplies) weeklyProductKeywords.some(keyword =>
const weeklyProducts = [ p.name.toLowerCase().includes(keyword.toLowerCase())
'Café en Grano', )
'Leche Entera', );
'Mantequilla', console.log('📦 OrderSuggestions: Weekly products:', weeklyProducts);
'Vasos de Café',
'Servilletas',
'Bolsas papel'
];
const suggestions: WeeklyOrderItem[] = []; const suggestions: WeeklyOrderItem[] = [];
const startDate = new Date().toISOString().split('T')[0];
for (const product of weeklyProducts) { for (const product of weeklyProducts) {
// Calculate weekly consumption based on analytics console.log('🔄 OrderSuggestions: Creating 7-day forecast for product:', product.name);
const weeklyConsumption = calculateWeeklyConsumption(product, analytics);
const currentStock = Math.round(weeklyConsumption * (0.3 + Math.random() * 0.4)); // Random stock between 30-70% of weekly need try {
const stockDays = Math.max(1, Math.round((currentStock / weeklyConsumption) * 7)); // Create single forecast for 7 days for this specific product
const forecastRequest = {
inventory_product_id: product.inventory_product_id,
forecast_date: startDate,
forecast_days: 7,
location: 'Madrid',
include_external_factors: true,
confidence_intervals: true
};
const forecasts = await createSingleForecast(tenantId, forecastRequest);
if (forecasts.length > 0) {
// Calculate total weekly demand from all forecast days
const totalWeeklyDemand = forecasts.reduce((sum, forecast) => sum + forecast.predicted_demand, 0);
const averageConfidence = forecasts.reduce((sum, forecast) => sum + (forecast.confidence_level || 0.8), 0) / forecasts.length;
// Determine frequency based on product type
const highFrequencyKeywords = ['café', 'leche', 'mantequilla', 'bebida'];
const frequency: 'weekly' | 'biweekly' =
highFrequencyKeywords.some(keyword => product.name.toLowerCase().includes(keyword)) ? 'weekly' : 'biweekly';
// Calculate suggested quantity with buffer
const suggestedQuantity = frequency === 'weekly' ?
Math.round(totalWeeklyDemand * 1.15) : // 15% buffer for weekly
Math.round(totalWeeklyDemand * 2.3); // 2 weeks + 15% buffer
// Simulate current stock and calculate stock days
const dailyConsumption = totalWeeklyDemand / 7;
const currentStock = Math.round(dailyConsumption * (2 + Math.random() * 8)); // 2-10 days worth
const stockDays = Math.max(1, Math.round(currentStock / dailyConsumption));
// Determine frequency const orderItem: WeeklyOrderItem = {
const frequency: 'weekly' | 'biweekly' = id: `weekly-${product.name.toLowerCase().replace(/\s+/g, '-')}`,
['Café en Grano', 'Leche Entera', 'Mantequilla'].includes(product) ? 'weekly' : 'biweekly'; product: product.name,
emoji: getProductEmoji(product.name),
suggestedQuantity,
currentStock,
unit: getProductUnit(product.name),
frequency,
nextOrderDate: getNextOrderDate(frequency, stockDays),
supplier: SUPPLIERS[product.name] || 'Proveedor General',
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product.name] || 1.0) * 100) / 100,
stockDays,
confidence: Math.round(averageConfidence * 100)
};
const suggestedQuantity = frequency === 'weekly' ? suggestions.push(orderItem);
Math.round(weeklyConsumption * 1.1) : // 10% buffer for weekly console.log(' OrderSuggestions: Added weekly suggestion:', orderItem);
Math.round(weeklyConsumption * 2.2); // 2 weeks + 10% buffer }
} catch (error) {
const orderItem: WeeklyOrderItem = { console.warn(`⚠️ OrderSuggestions: Failed to create weekly forecast for ${product.name}:`, error);
id: `weekly-${product.toLowerCase().replace(/\s+/g, '-')}`, // Continue with other products even if one fails
product, }
emoji: getProductEmoji(product),
suggestedQuantity,
currentStock,
unit: getProductUnit(product),
frequency,
nextOrderDate: getNextOrderDate(frequency, stockDays),
supplier: SUPPLIERS[product] || 'Proveedor General',
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 1.0) * 100) / 100,
stockDays,
confidence: stockDays <= 2 ? 95 : stockDays <= 5 ? 85 : 75
};
suggestions.push(orderItem);
} }
return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency console.log('🎯 OrderSuggestions: Final weekly suggestions:', suggestions);
}, [tenantId, getSalesAnalytics]); return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency (lowest stock days first)
} catch (error) {
console.error('❌ OrderSuggestions: Error in generateWeeklyOrderSuggestions:', error);
return [];
}
}, [tenantId, getProductsList, createSingleForecast]);
// Load order suggestions // Load order suggestions
const loadOrderSuggestions = useCallback(async () => { const loadOrderSuggestions = useCallback(async () => {
@@ -282,7 +332,13 @@ function getProductEmoji(product: string): string {
'Mantequilla': '🧈', 'Mantequilla': '🧈',
'Vasos de Café': '🥤', 'Vasos de Café': '🥤',
'Servilletas': '🧻', 'Servilletas': '🧻',
'Bolsas papel': '🛍️' 'Bolsas papel': '🛍️',
'Bebida': '🥤',
'Refresco': '🥤',
'Agua': '💧',
'Tarta': '🎂',
'Bollo': '🥖',
'Empanada': '🥟'
}; };
return emojiMap[product] || '📦'; return emojiMap[product] || '📦';
} }
@@ -298,25 +354,17 @@ function getProductUnit(product: string): string {
'Mantequilla': 'kg', 'Mantequilla': 'kg',
'Vasos de Café': 'unidades', 'Vasos de Café': 'unidades',
'Servilletas': 'paquetes', 'Servilletas': 'paquetes',
'Bolsas papel': 'unidades' 'Bolsas papel': 'unidades',
'Bebida': 'litros',
'Refresco': 'litros',
'Agua': 'litros',
'Tarta': 'unidades',
'Bollo': 'unidades',
'Empanada': 'unidades'
}; };
return unitMap[product] || 'unidades'; return unitMap[product] || 'unidades';
} }
function calculateWeeklyConsumption(product: string, analytics: any): number {
// This would ideally come from sales analytics
// For now, use realistic estimates based on product type
const weeklyEstimates: Record<string, number> = {
'Café en Grano': 5, // 5kg per week
'Leche Entera': 25, // 25L per week
'Mantequilla': 3, // 3kg per week
'Vasos de Café': 500, // 500 cups per week
'Servilletas': 10, // 10 packs per week
'Bolsas papel': 200 // 200 bags per week
};
return weeklyEstimates[product] || 10;
}
function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): string { function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): string {
const today = new Date(); const today = new Date();
@@ -333,97 +381,3 @@ function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number):
return nextDate.toISOString().split('T')[0]; return nextDate.toISOString().split('T')[0];
} }
// Mock data functions for when tenant ID is not available
function getMockDailyOrders(): DailyOrderItem[] {
return [
{
id: 'daily-pan-molde',
product: 'Pan de Molde',
emoji: '🍞',
suggestedQuantity: 25,
currentQuantity: 5,
unit: 'unidades',
urgency: 'medium' as const,
reason: 'Predicción: 25 unidades (tendencia estable)',
confidence: 85,
supplier: 'Panadería Central Madrid',
estimatedCost: 45.00,
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
},
{
id: 'daily-baguettes',
product: 'Baguettes',
emoji: '🥖',
suggestedQuantity: 20,
currentQuantity: 3,
unit: 'unidades',
urgency: 'high' as const,
reason: 'Predicción: 20 unidades (tendencia al alza)',
confidence: 92,
supplier: 'Panadería Central Madrid',
estimatedCost: 56.00,
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
},
{
id: 'daily-croissants',
product: 'Croissants',
emoji: '🥐',
suggestedQuantity: 15,
currentQuantity: 8,
unit: 'unidades',
urgency: 'low' as const,
reason: 'Predicción: 15 unidades (demanda regular)',
confidence: 78,
supplier: 'Panadería Central Madrid',
estimatedCost: 37.50,
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
}
];
}
function getMockWeeklyOrders(): WeeklyOrderItem[] {
return [
{
id: 'weekly-cafe-grano',
product: 'Café en Grano',
emoji: '☕',
suggestedQuantity: 5,
currentStock: 2,
unit: 'kg',
frequency: 'weekly' as const,
nextOrderDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
supplier: 'Cafés Premium',
estimatedCost: 87.50,
stockDays: 3,
confidence: 95
},
{
id: 'weekly-leche-entera',
product: 'Leche Entera',
emoji: '🥛',
suggestedQuantity: 25,
currentStock: 15,
unit: 'litros',
frequency: 'weekly' as const,
nextOrderDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
supplier: 'Lácteos Frescos SA',
estimatedCost: 23.75,
stockDays: 6,
confidence: 88
},
{
id: 'weekly-vasos-cafe',
product: 'Vasos de Café',
emoji: '🥤',
suggestedQuantity: 500,
currentStock: 100,
unit: 'unidades',
frequency: 'biweekly' as const,
nextOrderDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
supplier: 'Suministros Hostelería',
estimatedCost: 40.00,
stockDays: 2,
confidence: 90
}
];
}

View File

@@ -10,6 +10,7 @@ import TodayProduction from '../../components/simple/TodayProduction';
import QuickActions from '../../components/simple/QuickActions'; import QuickActions from '../../components/simple/QuickActions';
import QuickOverview from '../../components/simple/QuickOverview'; import QuickOverview from '../../components/simple/QuickOverview';
import OrderSuggestions from '../../components/simple/OrderSuggestions'; import OrderSuggestions from '../../components/simple/OrderSuggestions';
import WeatherContext from '../../components/simple/WeatherContext';
interface DashboardPageProps { interface DashboardPageProps {
onNavigateToOrders?: () => void; onNavigateToOrders?: () => void;
@@ -133,9 +134,12 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
{weather && ( {weather && (
<div className="flex items-center text-sm text-gray-600 bg-gray-50 rounded-lg px-4 py-2"> <div className="flex items-center text-sm text-gray-600 bg-gray-50 rounded-lg px-4 py-2">
<span className="text-lg mr-2"> <span className="text-lg mr-2">
{weather.precipitation > 0 ? '🌧️' : weather.temperature > 20 ? '☀️' : '⛅'} {weather.precipitation && weather.precipitation > 0 ? '🌧️' : weather.temperature && weather.temperature > 20 ? '☀️' : '⛅'}
</span> </span>
<span>{weather.temperature}°C</span> <div className="flex flex-col">
<span>{weather.temperature?.toFixed(1) || '--'}°C</span>
<span className="text-xs text-gray-500">AEMET</span>
</div>
</div> </div>
)} )}
@@ -217,6 +221,9 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
/> />
)} )}
{/* Weather & Context - Comprehensive AEMET Data */}
<WeatherContext />
{/* Production Section - Core Operations */} {/* Production Section - Core Operations */}
<TodayProduction <TodayProduction
items={mockProduction} items={mockProduction}
@@ -234,25 +241,6 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
onNavigateToReports={onNavigateToReports} onNavigateToReports={onNavigateToReports}
/> />
{/* Weather Impact Alert - Context Aware */}
{weather && weather.precipitation > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start">
<span className="text-2xl mr-3">🌧</span>
<div>
<h4 className="font-medium text-blue-900">Impacto del Clima Detectado</h4>
<p className="text-blue-800 text-sm mt-1">
Se esperan precipitaciones ({weather.precipitation}mm). Las predicciones se han ajustado
automáticamente considerando una reducción del 15% en el tráfico.
</p>
<div className="mt-2 flex items-center text-xs text-blue-700">
<div className="w-2 h-2 bg-blue-500 rounded-full mr-2"></div>
Producción y pedidos ya optimizados
</div>
</div>
</div>
</div>
)}
{/* Success Message - When Everything is Good */} {/* Success Message - When Everything is Good */}
{realAlerts.length === 0 && ( {realAlerts.length === 0 && (

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { POSManagementPage } from '../../components/pos';
interface POSPageProps {
view?: 'integrations' | 'sync-status' | 'transactions';
}
const POSPage: React.FC<POSPageProps> = ({ view = 'integrations' }) => {
// For now, all views route to the main POS management page
// In the future, you can create separate components for different views
switch (view) {
case 'integrations':
return (
<div className="p-6">
<POSManagementPage />
</div>
);
case 'sync-status':
return (
<div className="p-6">
{/* Future: Create dedicated sync status view */}
<POSManagementPage />
</div>
);
case 'transactions':
return (
<div className="p-6">
{/* Future: Create dedicated transactions view */}
<POSManagementPage />
</div>
);
default:
return (
<div className="p-6">
<POSManagementPage />
</div>
);
}
};
export default POSPage;

View File

@@ -0,0 +1 @@
export { default as POSPage } from './POSPage';

View File

@@ -20,6 +20,7 @@ import OrdersPage from '../pages/orders/OrdersPage';
import InventoryPage from '../pages/inventory/InventoryPage'; import InventoryPage from '../pages/inventory/InventoryPage';
import SalesPage from '../pages/sales/SalesPage'; import SalesPage from '../pages/sales/SalesPage';
import RecipesPage from '../pages/recipes/RecipesPage'; import RecipesPage from '../pages/recipes/RecipesPage';
import POSPage from '../pages/pos/POSPage';
// Analytics Hub Pages // Analytics Hub Pages
import AnalyticsLayout from '../components/layout/AnalyticsLayout'; import AnalyticsLayout from '../components/layout/AnalyticsLayout';
@@ -177,10 +178,27 @@ export const router = createBrowserRouter([
{ {
path: 'customer-orders', path: 'customer-orders',
element: <SalesPage view="customer-orders" /> element: <SalesPage view="customer-orders" />
}
]
},
{
path: 'pos',
children: [
{
index: true,
element: <POSPage />
}, },
{ {
path: 'pos-integration', path: 'integrations',
element: <SalesPage view="pos-integration" /> element: <POSPage view="integrations" />
},
{
path: 'sync-status',
element: <POSPage view="sync-status" />
},
{
path: 'transactions',
element: <POSPage view="transactions" />
} }
] ]
}, },

View File

@@ -522,20 +522,39 @@ class AEMETClient(BaseAPIClient):
async def get_current_weather(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]: async def get_current_weather(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]:
"""Get current weather for coordinates""" """Get current weather for coordinates"""
try: try:
logger.info("🌤️ Getting current weather from AEMET",
lat=latitude, lon=longitude, api_key_configured=bool(self.api_key))
# Check if API key is configured
if not self.api_key:
logger.error("❌ AEMET API key not configured - falling back to synthetic data")
return await self._get_synthetic_current_weather()
station_id = self.location_service.find_nearest_station(latitude, longitude) station_id = self.location_service.find_nearest_station(latitude, longitude)
if not station_id: if not station_id:
logger.warning("No weather station found", lat=latitude, lon=longitude) logger.warning("No weather station found for coordinates",
lat=latitude, lon=longitude,
madrid_bounds=f"{AEMETConstants.MADRID_BOUNDS.min_lat}-{AEMETConstants.MADRID_BOUNDS.max_lat}")
return await self._get_synthetic_current_weather() return await self._get_synthetic_current_weather()
logger.info("✅ Found nearest weather station", station_id=station_id)
weather_data = await self._fetch_current_weather_data(station_id) weather_data = await self._fetch_current_weather_data(station_id)
if weather_data: if weather_data:
return self.parser.parse_current_weather(weather_data) logger.info("✅ Successfully fetched AEMET weather data", station_id=station_id)
parsed_data = self.parser.parse_current_weather(weather_data)
# Ensure the source is set to AEMET for successful API calls
if parsed_data and isinstance(parsed_data, dict):
parsed_data["source"] = WeatherSource.AEMET.value
return parsed_data
logger.info("Falling back to synthetic weather data", reason="invalid_weather_data") logger.warning("❌ AEMET API returned no data - falling back to synthetic",
station_id=station_id, reason="invalid_weather_data")
return await self._get_synthetic_current_weather() return await self._get_synthetic_current_weather()
except Exception as e: except Exception as e:
logger.error("Failed to get current weather", error=str(e)) logger.error("❌ AEMET API failed - falling back to synthetic",
error=str(e), error_type=type(e).__name__)
return await self._get_synthetic_current_weather() return await self._get_synthetic_current_weather()
async def get_forecast(self, latitude: float, longitude: float, days: int = 7) -> List[Dict[str, Any]]: async def get_forecast(self, latitude: float, longitude: float, days: int = 7) -> List[Dict[str, Any]]: