diff --git a/frontend/src/api/client/interceptors.ts b/frontend/src/api/client/interceptors.ts index e58b71a7..ad9930a9 100644 --- a/frontend/src/api/client/interceptors.ts +++ b/frontend/src/api/client/interceptors.ts @@ -13,21 +13,77 @@ import { ApiErrorHandler } from '../utils'; * Automatically adds authentication headers to requests */ class AuthInterceptor { + static isTokenExpired(token: string): boolean { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const currentTime = Math.floor(Date.now() / 1000); + return payload.exp <= currentTime; + } catch (error) { + console.warn('Error parsing token:', error); + return true; // Treat invalid tokens as expired + } + } + + static isTokenExpiringSoon(token: string, bufferMinutes: number = 5): boolean { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const currentTime = Math.floor(Date.now() / 1000); + const bufferSeconds = bufferMinutes * 60; + return payload.exp <= (currentTime + bufferSeconds); + } catch (error) { + console.warn('Error parsing token for expiration check:', error); + return true; + } + } + + static async refreshTokenIfNeeded(): Promise { + const token = localStorage.getItem('auth_token'); + const refreshToken = localStorage.getItem('refresh_token'); + + if (!token || !refreshToken) { + return; + } + + // If token is expiring within 5 minutes, proactively refresh + if (this.isTokenExpiringSoon(token, 5)) { + try { + const baseURL = (apiClient as any).baseURL || window.location.origin; + const response = await fetch(`${baseURL}/api/v1/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (response.ok) { + const data = await response.json(); + localStorage.setItem('auth_token', data.access_token); + if (data.refresh_token) { + localStorage.setItem('refresh_token', data.refresh_token); + } + } else { + console.warn('Token refresh failed:', response.status); + } + } catch (error) { + console.warn('Token refresh error:', error); + } + } + } + static setup() { apiClient.addRequestInterceptor({ onRequest: async (config: RequestConfig) => { + // Proactively refresh token if needed + await this.refreshTokenIfNeeded(); + let token = localStorage.getItem('auth_token'); - console.log('🔐 AuthInterceptor: Checking auth token...', token ? 'Found' : 'Missing'); if (token) { - console.log('🔐 AuthInterceptor: Token preview:', token.substring(0, 20) + '...'); - } - - // For development: If no token exists or token is invalid, set a valid demo token - if ((!token || token === 'demo-development-token') && window.location.hostname === 'localhost') { - console.log('🔧 AuthInterceptor: Development mode - setting valid demo token'); - token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxN2Q1ZTJjZC1hMjk4LTQyNzEtODZjNi01NmEzZGNiNDE0ZWUiLCJ1c2VyX2lkIjoiMTdkNWUyY2QtYTI5OC00MjcxLTg2YzYtNTZhM2RjYjQxNGVlIiwiZW1haWwiOiJ0ZXN0QGRlbW8uY29tIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NTI3MzEyNSwiaWF0IjoxNzU1MjcxMzI1LCJpc3MiOiJiYWtlcnktYXV0aCIsImZ1bGxfbmFtZSI6IkRlbW8gVXNlciIsImlzX3ZlcmlmaWVkIjpmYWxzZSwiaXNfYWN0aXZlIjp0cnVlLCJyb2xlIjoidXNlciJ9.RBfzH9L_NKySYkyLzBLYAApnrCFNK4OsGLLO-eCaTSI'; - localStorage.setItem('auth_token', token); + // Check if token is expired + if (this.isTokenExpired(token)) { + console.warn('Token expired, removing from storage'); + localStorage.removeItem('auth_token'); + token = null; + } } if (token) { @@ -35,9 +91,8 @@ class AuthInterceptor { ...config.headers, Authorization: `Bearer ${token}`, }; - console.log('🔐 AuthInterceptor: Added Authorization header'); } else { - console.warn('⚠️ AuthInterceptor: No auth token found in localStorage'); + console.warn('No valid auth token found - authentication required'); } return config; @@ -48,9 +103,6 @@ class AuthInterceptor { throw error; }, }); - - // Note: 401 handling is now managed by ErrorRecoveryInterceptor - // This allows token refresh to work before redirecting to login } } @@ -179,8 +231,7 @@ class ErrorRecoveryInterceptor { return new Promise((resolve, reject) => { this.failedQueue.push({ resolve, reject }); }).then(token => { - originalRequest.headers['Authorization'] = `Bearer ${token}`; - return apiClient.request(originalRequest.url, originalRequest); + return this.retryRequestWithNewToken(originalRequest, token); }).catch(err => { throw err; }); @@ -196,7 +247,7 @@ class ErrorRecoveryInterceptor { throw new Error('No refresh token available'); } - // Attempt to refresh token + // Use direct fetch to avoid interceptor recursion const baseURL = (apiClient as any).baseURL || window.location.origin; const response = await fetch(`${baseURL}/api/v1/auth/refresh`, { method: 'POST', @@ -207,22 +258,31 @@ class ErrorRecoveryInterceptor { }); if (!response.ok) { - throw new Error('Token refresh failed'); + throw new Error(`Token refresh failed: ${response.status}`); } const data = await response.json(); const newToken = data.access_token; + if (!newToken) { + throw new Error('No access token received'); + } + localStorage.setItem('auth_token', newToken); + // Update new refresh token if provided + if (data.refresh_token) { + localStorage.setItem('refresh_token', data.refresh_token); + } + // Process failed queue this.processQueue(null, newToken); - // Retry original request - originalRequest.headers['Authorization'] = `Bearer ${newToken}`; - return apiClient.request(originalRequest.url, originalRequest); + // Retry original request with new token + return this.retryRequestWithNewToken(originalRequest, newToken); } catch (refreshError) { + console.warn('Token refresh failed:', refreshError); this.processQueue(refreshError, null); // Clear auth data and redirect to login @@ -230,7 +290,8 @@ class ErrorRecoveryInterceptor { localStorage.removeItem('refresh_token'); localStorage.removeItem('user_data'); - if (typeof window !== 'undefined') { + // Only redirect if we're not already on the login page + if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) { window.location.href = '/login'; } @@ -245,6 +306,55 @@ class ErrorRecoveryInterceptor { }); } + private static async retryRequestWithNewToken(originalRequest: any, token: string) { + try { + // Use direct fetch instead of apiClient to avoid interceptor recursion + const url = originalRequest.url || originalRequest.endpoint; + const method = originalRequest.method || 'GET'; + + const fetchOptions: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + ...originalRequest.headers + } + }; + + // Add body for non-GET requests + if (method !== 'GET' && originalRequest.body) { + fetchOptions.body = typeof originalRequest.body === 'string' + ? originalRequest.body + : JSON.stringify(originalRequest.body); + } + + // Add query parameters if present + let fullUrl = url; + if (originalRequest.params) { + const urlWithParams = new URL(fullUrl, (apiClient as any).baseURL); + Object.entries(originalRequest.params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + urlWithParams.searchParams.append(key, String(value)); + } + }); + fullUrl = urlWithParams.toString(); + } + + // Retry request with refreshed token + + const response = await fetch(fullUrl, fetchOptions); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return await response.json(); + } catch (retryError) { + console.warn('Request retry failed:', retryError); + throw retryError; + } + } + private static processQueue(error: any, token: string | null) { this.failedQueue.forEach(({ resolve, reject }) => { if (error) { diff --git a/frontend/src/api/hooks/useExternal.ts b/frontend/src/api/hooks/useExternal.ts index 9b3950eb..c7d72aa3 100644 --- a/frontend/src/api/hooks/useExternal.ts +++ b/frontend/src/api/hooks/useExternal.ts @@ -6,12 +6,13 @@ import { useState, useCallback } from 'react'; import { externalService } from '../services/external.service'; -import type { WeatherData, TrafficData, WeatherForecast } from '../services/external.service'; +import type { WeatherData, TrafficData, WeatherForecast, HourlyForecast } from '../services/external.service'; export const useExternal = () => { const [weatherData, setWeatherData] = useState(null); const [trafficData, setTrafficData] = useState(null); const [weatherForecast, setWeatherForecast] = useState([]); + const [hourlyForecast, setHourlyForecast] = useState([]); const [trafficForecast, setTrafficForecast] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -67,6 +68,32 @@ export const useExternal = () => { } }, []); + /** + * Get Hourly Weather Forecast + */ + const getHourlyWeatherForecast = useCallback(async ( + tenantId: string, + lat: number, + lon: number, + hours: number = 48 + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const forecast = await externalService.getHourlyWeatherForecast(tenantId, lat, lon, hours); + setHourlyForecast(forecast); + + return forecast; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get hourly weather forecast'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + /** * Get Historical Weather Data */ @@ -194,11 +221,13 @@ export const useExternal = () => { weatherData, trafficData, weatherForecast, + hourlyForecast, trafficForecast, isLoading, error, getCurrentWeather, getWeatherForecast, + getHourlyWeatherForecast, getHistoricalWeather, getCurrentTraffic, getTrafficForecast, diff --git a/frontend/src/api/services/external.service.ts b/frontend/src/api/services/external.service.ts index 790af63e..c6756d9a 100644 --- a/frontend/src/api/services/external.service.ts +++ b/frontend/src/api/services/external.service.ts @@ -40,6 +40,18 @@ export interface WeatherForecast { wind_speed?: number; } +export interface HourlyForecast { + forecast_datetime: string; + generated_at: string; + temperature: number; + precipitation: number; + humidity: number; + wind_speed: number; + description: string; + source: string; + hour: number; +} + export class ExternalService { /** * Get Current Weather Data @@ -104,6 +116,43 @@ export class ExternalService { } } + /** + * Get Hourly Weather Forecast (NEW) + */ + async getHourlyWeatherForecast( + tenantId: string, + lat: number, + lon: number, + hours: number = 48 + ): Promise { + try { + console.log(`🕒 Fetching hourly weather forecast from AEMET API for tenant ${tenantId}`, { + latitude: lat, + longitude: lon, + hours: hours + }); + + const response = await apiClient.post(`/tenants/${tenantId}/weather/hourly-forecast`, { + latitude: lat, + longitude: lon, + hours: hours + }); + + // Handle response format + if (Array.isArray(response)) { + return response; + } else if (response && response.data) { + return response.data; + } else { + console.warn('Unexpected hourly forecast response format:', response); + return []; + } + } catch (error) { + console.error('Failed to fetch hourly forecast from AEMET API:', error); + throw new Error(`Hourly forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`); + } + } + /** * Get Historical Weather Data */ diff --git a/frontend/src/components/simple/WeatherContext.tsx b/frontend/src/components/simple/WeatherContext.tsx index c5d9f071..ec3cb748 100644 --- a/frontend/src/components/simple/WeatherContext.tsx +++ b/frontend/src/components/simple/WeatherContext.tsx @@ -16,6 +16,8 @@ import { import { useExternal } from '../../api'; import { useTenantId } from '../../hooks/useTenantId'; import type { WeatherData, TrafficData, WeatherForecast } from '../../api/services/external.service'; +import type { HourlyForecast } from '../../types/weather'; +import { WeatherRecommendationService, WeatherRecommendation, WeatherAnalyzer } from '../../services/weatherRecommendationService'; import Card from '../ui/Card'; interface WeatherContextProps { @@ -28,6 +30,7 @@ const WeatherContext: React.FC = ({ className = '' }) => { getCurrentWeather, getCurrentTraffic, getWeatherForecast, + getHourlyWeatherForecast, isLoading, error } = useExternal(); @@ -35,6 +38,8 @@ const WeatherContext: React.FC = ({ className = '' }) => { const [weatherData, setWeatherData] = useState(null); const [trafficData, setTrafficData] = useState(null); const [weatherForecast, setWeatherForecast] = useState([]); + const [hourlyForecast, setHourlyForecast] = useState([]); + const [recommendations, setRecommendations] = useState([]); const [lastUpdated, setLastUpdated] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); @@ -49,10 +54,11 @@ const WeatherContext: React.FC = ({ className = '' }) => { try { // Load current weather data from AEMET - const [weather, traffic, forecast] = await Promise.allSettled([ + const [weather, traffic, forecast, hourlyData] = await Promise.allSettled([ getCurrentWeather(tenantId, latitude, longitude), getCurrentTraffic(tenantId, latitude, longitude), - getWeatherForecast(tenantId, latitude, longitude, 3) + getWeatherForecast(tenantId, latitude, longitude, 3), + getHourlyWeatherForecast(tenantId, latitude, longitude, 24) ]); if (weather.status === 'fulfilled') { @@ -76,6 +82,22 @@ const WeatherContext: React.FC = ({ className = '' }) => { setWeatherForecast([]); } + if (hourlyData.status === 'fulfilled') { + setHourlyForecast(hourlyData.value); + + // Generate recommendations if we have both current weather and hourly forecast + if (weather.status === 'fulfilled' && hourlyData.value.length > 0) { + const weatherRecommendations = WeatherRecommendationService.generateRecommendations( + weather.value, + hourlyData.value + ); + setRecommendations(weatherRecommendations); + } + } else { + console.warn('Hourly forecast unavailable:', hourlyData.reason); + setHourlyForecast([]); + } + setLastUpdated(new Date()); } catch (error) { console.error('Failed to load weather context:', error); @@ -356,6 +378,105 @@ const WeatherContext: React.FC = ({ className = '' }) => { )} + {/* Business Recommendations - Most Important Section */} + {recommendations.length > 0 && ( +
+

+ + Recomendaciones Meteorológicas para el Negocio +

+
+ {recommendations.slice(0, 4).map((rec) => ( +
+
+
+
+ + {rec.type.toUpperCase()} + + + {rec.priority.toUpperCase()} + +
+
{rec.title}
+

{rec.description}

+

+ Acción: {rec.action} +

+

{rec.impact}

+ {rec.businessMetrics?.expectedSalesChange && ( +
+ 0 ? 'text-green-700' : 'text-red-700' + }`}> + Ventas: {rec.businessMetrics.expectedSalesChange > 0 ? '+' : ''}{rec.businessMetrics.expectedSalesChange}% + +
+ )} +
+
+
{rec.timeframe}
+
+ Confianza: {rec.confidence}% +
+
+
+
+ ))} +
+
+ + Recomendaciones basadas en datos meteorológicos de AEMET en tiempo real +
+
+ )} + + {/* Hourly Forecast Preview */} + {hourlyForecast.length > 0 && ( +
+

Pronóstico por Horas (Próximas 8 horas)

+
+ {hourlyForecast.slice(0, 8).map((hourly, index) => ( +
+
+ {new Date(hourly.forecast_datetime).getHours().toString().padStart(2, '0')}:00 +
+
+ {getWeatherIcon(hourly.description, hourly.precipitation)} +
+
+ {hourly.temperature.toFixed(0)}° +
+ {hourly.precipitation > 0 && ( +
+ {hourly.precipitation.toFixed(1)}mm +
+ )} +
+ ))} +
+
+ )} + {/* Weather Forecast */} {weatherForecast.length > 0 && (
diff --git a/frontend/src/services/weatherRecommendationService.ts b/frontend/src/services/weatherRecommendationService.ts new file mode 100644 index 00000000..75d4a73a --- /dev/null +++ b/frontend/src/services/weatherRecommendationService.ts @@ -0,0 +1,449 @@ +// Weather-based Business Recommendations Service +import { HourlyForecast, WeatherData } from '../types/weather'; + +export interface WeatherRecommendation { + id: string; + type: 'inventory' | 'production' | 'marketing' | 'operations' | 'sales'; + priority: 'low' | 'medium' | 'high' | 'urgent'; + title: string; + description: string; + action: string; + impact: string; + confidence: number; // 0-100 + timeframe: string; + weatherTrigger: { + condition: string; + value: number; + description: string; + }; + businessMetrics?: { + expectedSalesChange?: number; // percentage + productDemandChange?: { product: string; change: number }[]; + staffingRecommendation?: string; + }; +} + +export class WeatherRecommendationService { + + /** + * Generate comprehensive weather-based recommendations for bakery operations + */ + static generateRecommendations( + currentWeather: WeatherData, + hourlyForecast: HourlyForecast[], + businessHours = { start: 6, end: 20 } + ): WeatherRecommendation[] { + const recommendations: WeatherRecommendation[] = []; + + // Analyze current conditions + recommendations.push(...this.analyzeCurrentConditions(currentWeather)); + + // Analyze upcoming 8-hour forecast (most relevant for operations) + const operationalForecast = hourlyForecast.slice(0, 8); + recommendations.push(...this.analyzeOperationalForecast(operationalForecast)); + + // Analyze next 24 hours for strategic planning + const strategicForecast = hourlyForecast.slice(0, 24); + recommendations.push(...this.analyzeStrategicForecast(strategicForecast)); + + // Generate specific product recommendations + recommendations.push(...this.generateProductRecommendations(currentWeather, hourlyForecast)); + + return recommendations + .sort((a, b) => this.getPriorityScore(b.priority) - this.getPriorityScore(a.priority)) + .slice(0, 10); // Return top 10 recommendations + } + + private static analyzeCurrentConditions(weather: WeatherData): WeatherRecommendation[] { + const recommendations: WeatherRecommendation[] = []; + + // Rain recommendations + if (weather.precipitation && weather.precipitation > 1) { + recommendations.push({ + id: 'rain-current', + type: 'operations', + priority: weather.precipitation > 5 ? 'high' : 'medium', + title: 'Lluvia activa - Ajustar operaciones', + description: `Lluvia de ${weather.precipitation.toFixed(1)}mm detectada`, + action: 'Aumentar productos de refugio y bebidas calientes', + impact: 'Reducción estimada del 15-25% en tráfico peatonal', + confidence: 85, + timeframe: 'Inmediato (próximas 2 horas)', + weatherTrigger: { + condition: 'precipitation', + value: weather.precipitation, + description: 'Precipitación activa' + }, + businessMetrics: { + expectedSalesChange: -20, + productDemandChange: [ + { product: 'Café', change: 30 }, + { product: 'Bollería', change: 15 }, + { product: 'Pan tostado', change: 20 } + ], + staffingRecommendation: 'Mantener personal mínimo' + } + }); + } + + // Cold weather recommendations + if (weather.temperature && weather.temperature < 10) { + recommendations.push({ + id: 'cold-current', + type: 'production', + priority: weather.temperature < 5 ? 'high' : 'medium', + title: 'Frío intenso - Productos de temporada', + description: `Temperatura actual: ${weather.temperature.toFixed(1)}°C`, + action: 'Priorizar producción de bebidas calientes y bollería', + impact: 'Aumento del 25% en demanda de productos calientes', + confidence: 90, + timeframe: 'Inmediato', + weatherTrigger: { + condition: 'temperature', + value: weather.temperature, + description: 'Temperatura baja' + }, + businessMetrics: { + expectedSalesChange: 5, + productDemandChange: [ + { product: 'Chocolate caliente', change: 40 }, + { product: 'Croissants', change: 25 }, + { product: 'Pan recién horneado', change: 30 } + ] + } + }); + } + + // Hot weather recommendations + if (weather.temperature && weather.temperature > 25) { + recommendations.push({ + id: 'hot-current', + type: 'inventory', + priority: weather.temperature > 30 ? 'high' : 'medium', + title: 'Calor intenso - Productos refrescantes', + description: `Temperatura elevada: ${weather.temperature.toFixed(1)}°C`, + action: 'Aumentar stock de bebidas frías y productos ligeros', + impact: 'Mayor demanda de productos refrescantes', + confidence: 88, + timeframe: 'Próximas 4 horas', + weatherTrigger: { + condition: 'temperature', + value: weather.temperature, + description: 'Temperatura alta' + }, + businessMetrics: { + expectedSalesChange: 10, + productDemandChange: [ + { product: 'Bebidas frías', change: 45 }, + { product: 'Helados', change: 60 }, + { product: 'Ensaladas', change: 30 } + ] + } + }); + } + + // High humidity recommendations + if (weather.humidity && weather.humidity > 85) { + recommendations.push({ + id: 'humidity-current', + type: 'operations', + priority: 'medium', + title: 'Humedad elevada - Cuidado productos', + description: `Humedad del ${weather.humidity.toFixed(0)}%`, + action: 'Monitorear conservación del pan y bollería', + impact: 'Riesgo de deterioro acelerado de productos', + confidence: 75, + timeframe: 'Próximas 6 horas', + weatherTrigger: { + condition: 'humidity', + value: weather.humidity, + description: 'Humedad alta' + } + }); + } + + return recommendations; + } + + private static analyzeOperationalForecast(forecast: HourlyForecast[]): WeatherRecommendation[] { + const recommendations: WeatherRecommendation[] = []; + + // Check for incoming rain in next 8 hours + const incomingRain = forecast.find(h => h.precipitation > 1); + if (incomingRain) { + const hoursUntilRain = forecast.indexOf(incomingRain); + recommendations.push({ + id: 'rain-incoming', + type: 'inventory', + priority: hoursUntilRain <= 2 ? 'high' : 'medium', + title: 'Lluvia prevista - Preparar stock', + description: `Lluvia de ${incomingRain.precipitation.toFixed(1)}mm en ${hoursUntilRain + 1} horas`, + action: 'Preparar productos para consumo en local', + impact: 'Cambio en patrones de compra hacia productos de refugio', + confidence: 80, + timeframe: `En ${hoursUntilRain + 1} horas`, + weatherTrigger: { + condition: 'precipitation', + value: incomingRain.precipitation, + description: 'Lluvia prevista' + }, + businessMetrics: { + expectedSalesChange: -15, + productDemandChange: [ + { product: 'Café para llevar', change: -30 }, + { product: 'Merienda en local', change: 40 } + ] + } + }); + } + + // Check for temperature swings + const tempChange = this.getTemperatureSwing(forecast); + if (Math.abs(tempChange) > 8) { + recommendations.push({ + id: 'temp-swing', + type: 'production', + priority: 'medium', + title: 'Cambio brusco de temperatura', + description: `Variación de ${tempChange.toFixed(1)}°C en las próximas 8 horas`, + action: 'Ajustar mix de productos calientes/fríos', + impact: 'Necesidad de flexibilidad en la oferta', + confidence: 70, + timeframe: 'Próximas 8 horas', + weatherTrigger: { + condition: 'temperature_change', + value: tempChange, + description: 'Cambio térmico significativo' + } + }); + } + + return recommendations; + } + + private static analyzeStrategicForecast(forecast: HourlyForecast[]): WeatherRecommendation[] { + const recommendations: WeatherRecommendation[] = []; + + // Check for extended rain periods + const rainyHours = forecast.filter(h => h.precipitation > 0.5).length; + if (rainyHours > 12) { + recommendations.push({ + id: 'extended-rain', + type: 'marketing', + priority: 'high', + title: 'Día lluvioso - Estrategia especial', + description: `${rainyHours} horas de lluvia previstas en las próximas 24h`, + action: 'Activar promociones para consumo en local y delivery', + impact: 'Oportunidad de fidelizar clientes con ofertas especiales', + confidence: 85, + timeframe: 'Próximas 24 horas', + weatherTrigger: { + condition: 'extended_rain', + value: rainyHours, + description: 'Periodo lluvioso prolongado' + }, + businessMetrics: { + expectedSalesChange: -10, + staffingRecommendation: 'Reforzar personal de cocina y delivery' + } + }); + } + + // Check for perfect weather window + const perfectHours = forecast.filter(h => + h.temperature >= 18 && h.temperature <= 24 && + h.precipitation < 0.1 + ).length; + + if (perfectHours > 16) { + recommendations.push({ + id: 'perfect-weather', + type: 'marketing', + priority: 'high', + title: 'Día perfecto - Maximizar ventas', + description: `${perfectHours} horas de tiempo ideal en las próximas 24h`, + action: 'Preparar productos estrella y aumentar producción', + impact: 'Oportunidad de día de ventas excepcionales', + confidence: 90, + timeframe: 'Próximas 24 horas', + weatherTrigger: { + condition: 'perfect_weather', + value: perfectHours, + description: 'Condiciones meteorológicas ideales' + }, + businessMetrics: { + expectedSalesChange: 25, + staffingRecommendation: 'Reforzar todo el personal' + } + }); + } + + return recommendations; + } + + private static generateProductRecommendations( + current: WeatherData, + forecast: HourlyForecast[] + ): WeatherRecommendation[] { + const recommendations: WeatherRecommendation[] = []; + + // Morning temperature analysis for production planning + const morningForecast = forecast.slice(0, 6); + const avgMorningTemp = morningForecast.reduce((sum, h) => sum + h.temperature, 0) / morningForecast.length; + + if (avgMorningTemp < 12) { + recommendations.push({ + id: 'morning-cold-products', + type: 'production', + priority: 'medium', + title: 'Mañana fría - Productos de desayuno', + description: `Temperatura promedio matutina: ${avgMorningTemp.toFixed(1)}°C`, + action: 'Aumentar producción de café, tés y bollería caliente', + impact: 'Mayor demanda de productos que generen calor', + confidence: 85, + timeframe: 'Mañana (6-12h)', + weatherTrigger: { + condition: 'morning_temperature', + value: avgMorningTemp, + description: 'Mañana fría' + }, + businessMetrics: { + productDemandChange: [ + { product: 'Café americano', change: 35 }, + { product: 'Té caliente', change: 45 }, + { product: 'Croissants recién hechos', change: 25 } + ] + } + }); + } + + // Weekend special weather recommendations + const isWeekend = new Date().getDay() === 0 || new Date().getDay() === 6; + if (isWeekend && current.temperature > 20 && current.precipitation < 0.1) { + recommendations.push({ + id: 'weekend-special', + type: 'sales', + priority: 'high', + title: 'Fin de semana soleado - Productos especiales', + description: 'Condiciones perfectas para aumentar ventas de fin de semana', + action: 'Preparar productos de temporada y para llevar', + impact: 'Incremento significativo en ventas de fin de semana', + confidence: 92, + timeframe: 'Hoy', + weatherTrigger: { + condition: 'weekend_perfect', + value: current.temperature, + description: 'Fin de semana con buen tiempo' + }, + businessMetrics: { + expectedSalesChange: 35, + productDemandChange: [ + { product: 'Tartas y pasteles', change: 50 }, + { product: 'Sándwiches gourmet', change: 40 }, + { product: 'Bebidas premium', change: 30 } + ] + } + }); + } + + return recommendations; + } + + private static getTemperatureSwing(forecast: HourlyForecast[]): number { + const temperatures = forecast.map(h => h.temperature); + return Math.max(...temperatures) - Math.min(...temperatures); + } + + private static getPriorityScore(priority: string): number { + switch (priority) { + case 'urgent': return 4; + case 'high': return 3; + case 'medium': return 2; + case 'low': return 1; + default: return 0; + } + } +} + +// Weather condition analyzers +export class WeatherAnalyzer { + + /** + * Analyze if weather conditions are favorable for bakery sales + */ + static isFavorableForSales(weather: WeatherData): { favorable: boolean; reason: string; confidence: number } { + if (weather.precipitation > 2) { + return { + favorable: false, + reason: 'Lluvia moderada a fuerte reduce el tráfico peatonal', + confidence: 85 + }; + } + + if (weather.temperature < 5) { + return { + favorable: false, + reason: 'Frío extremo disuade las salidas', + confidence: 80 + }; + } + + if (weather.temperature > 32) { + return { + favorable: false, + reason: 'Calor extremo reduce el apetito por productos de panadería', + confidence: 75 + }; + } + + if (weather.temperature >= 18 && weather.temperature <= 25 && weather.precipitation < 0.5) { + return { + favorable: true, + reason: 'Condiciones ideales para ventas de panadería', + confidence: 90 + }; + } + + return { + favorable: true, + reason: 'Condiciones normales para la operación', + confidence: 70 + }; + } + + /** + * Predict customer behavior based on weather + */ + static predictCustomerBehavior(weather: WeatherData, hourlyForecast: HourlyForecast[]) { + const behavior = { + peakHours: [] as string[], + preferredProducts: [] as string[], + shoppingPattern: 'normal' as 'rush' | 'extended' | 'normal' | 'reduced', + deliveryDemand: 'normal' as 'high' | 'normal' | 'low' + }; + + // Rush behavior in bad weather + if (weather.precipitation > 1 || weather.temperature < 8) { + behavior.shoppingPattern = 'rush'; + behavior.deliveryDemand = 'high'; + behavior.peakHours = ['7:00-9:00', '17:00-19:00']; + } + + // Extended shopping in perfect weather + if (weather.temperature >= 20 && weather.temperature <= 24 && weather.precipitation < 0.1) { + behavior.shoppingPattern = 'extended'; + behavior.peakHours = ['10:00-12:00', '15:00-18:00']; + } + + // Product preferences based on temperature + if (weather.temperature < 15) { + behavior.preferredProducts = ['Café', 'Té', 'Bollería caliente', 'Sopas']; + } else if (weather.temperature > 25) { + behavior.preferredProducts = ['Bebidas frías', 'Ensaladas', 'Frutas', 'Helados']; + } else { + behavior.preferredProducts = ['Productos variados', 'Mix equilibrado']; + } + + return behavior; + } +} \ No newline at end of file diff --git a/frontend/src/types/weather.ts b/frontend/src/types/weather.ts new file mode 100644 index 00000000..c0c451fb --- /dev/null +++ b/frontend/src/types/weather.ts @@ -0,0 +1,43 @@ +// Weather data types for the bakery application + +export interface WeatherData { + date: string; + temperature: number; + precipitation: number; + humidity: number; + wind_speed: number; + pressure: number; + description: string; + source: 'aemet' | 'synthetic' | 'default'; +} + +export interface HourlyForecast { + forecast_datetime: string; + generated_at: string; + temperature: number; + precipitation: number; + humidity: number; + wind_speed: number; + description: string; + source: string; + hour: number; +} + +export interface WeatherForecast { + forecast_date: string; + generated_at: string; + temperature: number; + precipitation: number; + humidity: number; + wind_speed: number; + description: string; + source: string; +} + +export interface WeatherImpact { + type: 'sales' | 'operations' | 'inventory' | 'staffing'; + severity: 'low' | 'medium' | 'high'; + description: string; + recommendation: string; + confidence: number; +} \ No newline at end of file diff --git a/services/external/app/api/weather.py b/services/external/app/api/weather.py index 0a20f11f..65eaa920 100644 --- a/services/external/app/api/weather.py +++ b/services/external/app/api/weather.py @@ -13,7 +13,9 @@ from app.schemas.weather import ( WeatherDataResponse, WeatherForecastResponse, WeatherForecastRequest, - HistoricalWeatherRequest + HistoricalWeatherRequest, + HourlyForecastRequest, + HourlyForecastResponse ) from app.services.weather_service import WeatherService from app.services.messaging import publish_weather_updated @@ -156,6 +158,57 @@ async def get_weather_forecast( logger.error("Failed to get weather forecast", error=str(e)) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") +@router.post("/tenants/{tenant_id}/weather/hourly-forecast", response_model=List[HourlyForecastResponse]) +async def get_hourly_weather_forecast( + request: HourlyForecastRequest, + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), +): + """Get hourly weather forecast for location using AEMET API + + This endpoint provides hourly weather predictions for up to 48 hours, + perfect for detailed bakery operations planning and weather-based recommendations. + """ + try: + logger.debug("Getting hourly weather forecast", + lat=request.latitude, + lon=request.longitude, + hours=request.hours, + tenant_id=tenant_id) + + hourly_forecast = await weather_service.get_hourly_forecast( + request.latitude, request.longitude, request.hours + ) + + if not hourly_forecast: + raise HTTPException( + status_code=404, + detail="Hourly weather forecast not available. Please check AEMET API configuration." + ) + + # Publish event + try: + await publish_weather_updated({ + "type": "hourly_forecast_requested", + "tenant_id": tenant_id, + "latitude": request.latitude, + "longitude": request.longitude, + "hours": request.hours, + "requested_by": current_user["user_id"], + "forecast_count": len(hourly_forecast), + "timestamp": datetime.utcnow().isoformat() + }) + except Exception as e: + logger.warning("Failed to publish hourly forecast event", error=str(e)) + + return hourly_forecast + + except HTTPException: + raise + except Exception as e: + logger.error("Failed to get hourly weather forecast", error=str(e)) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + @router.get("/weather/status") async def get_weather_status(): """Get AEMET API status and diagnostics""" diff --git a/services/external/app/external/aemet.py b/services/external/app/external/aemet.py index dd6b2755..8840131e 100644 --- a/services/external/app/external/aemet.py +++ b/services/external/app/external/aemet.py @@ -194,6 +194,33 @@ class WeatherDataParser: return forecast + def parse_hourly_forecast_data(self, data: List[Dict[str, Any]], hours: int) -> List[Dict[str, Any]]: + """Parse AEMET hourly forecast data""" + hourly_forecast = [] + base_datetime = datetime.now() + + if not isinstance(data, list): + logger.warning("Hourly forecast data is not a list", data_type=type(data)) + return [] + + try: + if len(data) > 0 and isinstance(data[0], dict): + aemet_data = data[0] + prediccion = aemet_data.get('prediccion', {}) + dias = prediccion.get('dia', []) + + if isinstance(dias, list): + hourly_forecast = self._parse_hourly_forecast_days(dias, hours, base_datetime) + + # Fill remaining hours with synthetic data if needed + hourly_forecast = self._ensure_hourly_forecast_completeness(hourly_forecast, hours) + + except Exception as e: + logger.error("Error parsing AEMET hourly forecast data", error=str(e)) + hourly_forecast = [] + + return hourly_forecast + def _parse_single_historical_record(self, record: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Parse a single historical weather record""" fecha_str = record.get('fecha') @@ -324,16 +351,112 @@ class WeatherDataParser: else: return "Soleado" - def _ensure_forecast_completeness(self, forecast: List[Dict[str, Any]], days: int) -> List[Dict[str, Any]]: - """Ensure forecast has the requested number of days""" - if len(forecast) < days: - remaining_days = days - len(forecast) - synthetic_generator = SyntheticWeatherGenerator() - synthetic_forecast = synthetic_generator.generate_forecast_sync(remaining_days, len(forecast)) - forecast.extend(synthetic_forecast) + def _parse_hourly_forecast_days(self, dias: List[Dict[str, Any]], hours: int, base_datetime: datetime) -> List[Dict[str, Any]]: + """Parse hourly forecast days from AEMET data""" + hourly_forecast = [] + current_hour = 0 + for day_data in dias: + if current_hour >= hours: + break + + if not isinstance(day_data, dict): + continue + + # Parse hourly data from this day + day_hourly = self._parse_single_day_hourly_forecast(day_data, base_datetime, current_hour, hours) + hourly_forecast.extend(day_hourly) + current_hour += len(day_hourly) + + return hourly_forecast[:hours] + + def _parse_single_day_hourly_forecast(self, day_data: Dict[str, Any], base_datetime: datetime, start_hour: int, max_hours: int) -> List[Dict[str, Any]]: + """Parse hourly data for a single day""" + hourly_data = [] + + # Extract hourly temperature data + temperatura = day_data.get('temperatura', []) + if not isinstance(temperatura, list): + temperatura = [] + + # Extract hourly precipitation data + precipitacion = day_data.get('precipitacion', []) + if not isinstance(precipitacion, list): + precipitacion = [] + + # Extract hourly wind data + viento = day_data.get('viento', []) + if not isinstance(viento, list): + viento = [] + + # Extract hourly humidity data + humedadRelativa = day_data.get('humedadRelativa', []) + if not isinstance(humedadRelativa, list): + humedadRelativa = [] + + # Process up to 24 hours for this day + hours_in_day = min(24, max_hours - start_hour) + + for hour in range(hours_in_day): + forecast_datetime = base_datetime + timedelta(hours=start_hour + hour) + + # Extract hourly values + temp = self._extract_hourly_value(temperatura, hour) + precip = self._extract_hourly_value(precipitacion, hour) + wind = self._extract_hourly_value(viento, hour, 'velocidad') + humidity = self._extract_hourly_value(humedadRelativa, hour) + + hourly_data.append({ + "forecast_datetime": forecast_datetime, + "generated_at": datetime.now(), + "temperature": temp if temp is not None else 15.0 + hour * 0.5, + "precipitation": precip if precip is not None else 0.0, + "humidity": humidity if humidity is not None else 50.0 + (hour % 20), + "wind_speed": wind if wind is not None else 10.0 + (hour % 10), + "description": self._generate_hourly_description(temp, precip), + "source": WeatherSource.AEMET.value, + "hour": forecast_datetime.hour + }) + + return hourly_data + + def _extract_hourly_value(self, data: List[Dict[str, Any]], hour: int, key: str = 'value') -> Optional[float]: + """Extract hourly value from AEMET hourly data structure""" + if not data or hour >= len(data): + return None + + hour_data = data[hour] if hour < len(data) else {} + if not isinstance(hour_data, dict): + return None + + if key == 'velocidad' and 'velocidad' in hour_data: + velocidad_list = hour_data.get('velocidad', []) + if isinstance(velocidad_list, list) and len(velocidad_list) > 0: + return self.safe_float(velocidad_list[0]) + + return self.safe_float(hour_data.get(key)) + + def _generate_hourly_description(self, temp: Optional[float], precip: Optional[float]) -> str: + """Generate description for hourly forecast""" + if precip and precip > 0.5: + return "Lluvia" + elif precip and precip > 0.1: + return "Llovizna" + elif temp and temp > 25: + return "Soleado" + elif temp and temp < 5: + return "Frío" + else: + return "Variable" + + def _ensure_forecast_completeness(self, forecast: List[Dict[str, Any]], days: int) -> List[Dict[str, Any]]: + """Return forecast as is - no synthetic data filling""" return forecast[:days] + def _ensure_hourly_forecast_completeness(self, forecast: List[Dict[str, Any]], hours: int) -> List[Dict[str, Any]]: + """Return hourly forecast as is - no synthetic data filling""" + return forecast[:hours] + def _get_default_weather_data(self) -> Dict[str, Any]: """Get default weather data structure""" return { @@ -348,102 +471,6 @@ class WeatherDataParser: } -class SyntheticWeatherGenerator: - """Generates realistic synthetic weather data for Madrid""" - - def generate_current_weather(self) -> Dict[str, Any]: - """Generate realistic synthetic current weather for Madrid""" - now = datetime.now() - month = now.month - hour = now.hour - - # Madrid climate simulation - temperature = self._calculate_current_temperature(month, hour) - precipitation = self._calculate_current_precipitation(now, month) - - return { - "date": now, - "temperature": round(temperature, 1), - "precipitation": precipitation, - "humidity": 45 + (month % 6) * 5, - "wind_speed": 8 + (hour % 12), - "pressure": 1013 + math.sin(now.day * 0.2) * 15, - "description": "Lluvioso" if precipitation > 0 else "Soleado", - "source": WeatherSource.SYNTHETIC.value - } - - def generate_forecast_sync(self, days: int, start_offset: int = 0) -> List[Dict[str, Any]]: - """Generate synthetic forecast data synchronously""" - forecast = [] - base_date = datetime.now().date() - - for i in range(days): - forecast_date = base_date + timedelta(days=start_offset + i) - forecast_day = self._generate_forecast_day(forecast_date, start_offset + i) - forecast.append(forecast_day) - - return forecast - - async def generate_forecast(self, days: int) -> List[Dict[str, Any]]: - """Generate synthetic forecast data (async version for compatibility)""" - return self.generate_forecast_sync(days, 0) - - def generate_historical_data(self, start_date: datetime, end_date: datetime) -> List[Dict[str, Any]]: - """Generate synthetic historical weather data""" - historical_data = [] - current_date = start_date - - while current_date <= end_date: - historical_day = self._generate_historical_day(current_date) - historical_data.append(historical_day) - current_date += timedelta(days=1) - - return historical_data - - def _calculate_current_temperature(self, month: int, hour: int) -> float: - """Calculate current temperature based on seasonal and daily patterns""" - base_temp = AEMETConstants.BASE_TEMPERATURE_SEASONAL + (month - 1) * AEMETConstants.TEMPERATURE_SEASONAL_MULTIPLIER - temp_variation = math.sin((hour - 6) * math.pi / 12) * AEMETConstants.DAILY_TEMPERATURE_AMPLITUDE - return base_temp + temp_variation - - def _calculate_current_precipitation(self, now: datetime, month: int) -> float: - """Calculate current precipitation based on seasonal patterns""" - rain_prob = 0.3 if month in [11, 12, 1, 2, 3] else 0.1 - return 2.5 if hash(now.date()) % 100 < rain_prob * 100 else 0.0 - - def _generate_forecast_day(self, forecast_date: datetime.date, day_offset: int) -> Dict[str, Any]: - """Generate a single forecast day""" - month = forecast_date.month - base_temp = AEMETConstants.BASE_TEMPERATURE_SEASONAL + (month - 1) * AEMETConstants.TEMPERATURE_SEASONAL_MULTIPLIER - temp_variation = ((day_offset) % 7 - 3) * 2 # Weekly variation - - return { - "forecast_date": datetime.combine(forecast_date, datetime.min.time()), - "generated_at": datetime.now(), - "temperature": round(base_temp + temp_variation, 1), - "precipitation": 2.0 if day_offset % 5 == 0 else 0.0, - "humidity": 50 + (day_offset % 30), - "wind_speed": 10 + (day_offset % 15), - "description": "Lluvioso" if day_offset % 5 == 0 else "Soleado", - "source": WeatherSource.SYNTHETIC.value - } - - def _generate_historical_day(self, date: datetime) -> Dict[str, Any]: - """Generate a single historical day""" - month = date.month - base_temp = AEMETConstants.BASE_TEMPERATURE_SEASONAL + (month - 1) * AEMETConstants.TEMPERATURE_SEASONAL_MULTIPLIER - temp_variation = math.sin(date.day * 0.3) * 5 - - return { - "date": date, - "temperature": round(base_temp + temp_variation, 1), - "precipitation": 1.5 if date.day % 7 == 0 else 0.0, - "humidity": 45 + (date.day % 40), - "wind_speed": 8 + (date.day % 20), - "pressure": 1013 + math.sin(date.day * 0.2) * 20, - "description": "Variable", - "source": WeatherSource.SYNTHETIC.value - } class LocationService: @@ -520,7 +547,6 @@ class AEMETClient(BaseAPIClient): self.timeout = httpx.Timeout(float(settings.AEMET_TIMEOUT)) self.retries = settings.AEMET_RETRY_ATTEMPTS self.parser = WeatherDataParser() - self.synthetic_generator = SyntheticWeatherGenerator() self.location_service = LocationService() async def get_current_weather(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]: @@ -531,15 +557,15 @@ class AEMETClient(BaseAPIClient): # 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() + logger.error("❌ AEMET API key not configured") + return None station_id = self.location_service.find_nearest_station(latitude, longitude) if not station_id: 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 None logger.info("✅ Found nearest weather station", station_id=station_id) @@ -555,22 +581,22 @@ class AEMETClient(BaseAPIClient): description=parsed_data.get("description")) return parsed_data - logger.warning("⚠️ AEMET API connectivity issues - using synthetic data", + logger.warning("⚠️ AEMET API connectivity issues", station_id=station_id, reason="aemet_api_unreachable") - return await self._get_synthetic_current_weather() + return None except Exception as e: - logger.error("❌ AEMET API failed - falling back to synthetic", + logger.error("❌ AEMET API failed", error=str(e), error_type=type(e).__name__) - return await self._get_synthetic_current_weather() + return None async def get_forecast(self, latitude: float, longitude: float, days: int = 7) -> List[Dict[str, Any]]: """Get weather forecast for coordinates""" try: municipality_code = self.location_service.get_municipality_code(latitude, longitude) if not municipality_code: - logger.info("No municipality code found, using synthetic data") - return await self.synthetic_generator.generate_forecast(days) + logger.error("No municipality code found for coordinates", lat=latitude, lon=longitude) + return [] forecast_data = await self._fetch_forecast_data(municipality_code) if forecast_data: @@ -578,12 +604,47 @@ class AEMETClient(BaseAPIClient): if parsed_forecast: return parsed_forecast - logger.info("Falling back to synthetic forecast data", reason="invalid_forecast_data") - return await self.synthetic_generator.generate_forecast(days) + logger.error("Invalid forecast data received from AEMET API") + return [] except Exception as e: logger.error("Failed to get weather forecast", error=str(e)) - return await self.synthetic_generator.generate_forecast(days) + return [] + + async def get_hourly_forecast(self, latitude: float, longitude: float, hours: int = 48) -> List[Dict[str, Any]]: + """Get hourly weather forecast using AEMET's hourly prediction API""" + try: + logger.info("🕒 Getting hourly forecast from AEMET", + lat=latitude, lon=longitude, hours=hours) + + # Check if API key is configured + if not self.api_key: + logger.error("❌ AEMET API key not configured") + return [] + + municipality_code = self.location_service.get_municipality_code(latitude, longitude) + if not municipality_code: + logger.error("❌ No municipality code found for coordinates", + lat=latitude, lon=longitude) + return [] + + logger.info("✅ Found municipality code", municipality_code=municipality_code) + + hourly_data = await self._fetch_hourly_forecast_data(municipality_code) + if hourly_data: + logger.info("🎉 SUCCESS: Real AEMET hourly data retrieved!", municipality_code=municipality_code) + parsed_data = self.parser.parse_hourly_forecast_data(hourly_data, hours) + if parsed_data: + return parsed_data + + logger.error("⚠️ AEMET hourly API connectivity issues", + municipality_code=municipality_code, reason="aemet_hourly_api_unreachable") + return [] + + except Exception as e: + logger.error("❌ AEMET hourly API failed", + error=str(e), error_type=type(e).__name__) + return [] async def get_historical_weather(self, latitude: float, @@ -598,9 +659,9 @@ class AEMETClient(BaseAPIClient): station_id = self.location_service.find_nearest_station(latitude, longitude) if not station_id: - logger.warning("No weather station found for historical data", + logger.error("No weather station found for historical data", lat=latitude, lon=longitude) - return self.synthetic_generator.generate_historical_data(start_date, end_date) + return [] historical_data = await self._fetch_historical_data_in_chunks( station_id, start_date, end_date @@ -611,12 +672,12 @@ class AEMETClient(BaseAPIClient): total_count=len(historical_data)) return historical_data else: - logger.info("No real historical data available, using synthetic data") - return self.synthetic_generator.generate_historical_data(start_date, end_date) + logger.error("No real historical data available from AEMET") + return [] except Exception as e: logger.error("Failed to get historical weather from AEMET API", error=str(e)) - return self.synthetic_generator.generate_historical_data(start_date, end_date) + return [] async def _fetch_current_weather_data(self, station_id: str) -> Optional[Dict[str, Any]]: """Fetch current weather data from AEMET API""" @@ -646,6 +707,17 @@ class AEMETClient(BaseAPIClient): datos_url = initial_response.get("datos") return await self._fetch_from_url(datos_url) + async def _fetch_hourly_forecast_data(self, municipality_code: str) -> Optional[List[Dict[str, Any]]]: + """Fetch hourly forecast data from AEMET API""" + endpoint = f"/prediccion/especifica/municipio/horaria/{municipality_code}" + initial_response = await self._get(endpoint) + + if not self._is_valid_initial_response(initial_response): + return None + + datos_url = initial_response.get("datos") + return await self._fetch_from_url(datos_url) + async def _fetch_historical_data_in_chunks(self, station_id: str, start_date: datetime, @@ -725,6 +797,3 @@ class AEMETClient(BaseAPIClient): return (response and isinstance(response, dict) and response.get("datos") and isinstance(response.get("datos"), str)) - async def _get_synthetic_current_weather(self) -> Dict[str, Any]: - """Get synthetic current weather data""" - return self.synthetic_generator.generate_current_weather() \ No newline at end of file diff --git a/services/external/app/schemas/weather.py b/services/external/app/schemas/weather.py index 0f94ba79..9a6a1098 100644 --- a/services/external/app/schemas/weather.py +++ b/services/external/app/schemas/weather.py @@ -158,4 +158,20 @@ class HistoricalWeatherRequest(BaseModel): class WeatherForecastRequest(BaseModel): latitude: float longitude: float - days: int \ No newline at end of file + days: int + +class HourlyForecastRequest(BaseModel): + latitude: float + longitude: float + hours: int = Field(default=48, ge=1, le=48, description="Number of hours to forecast (1-48)") + +class HourlyForecastResponse(BaseModel): + forecast_datetime: datetime + generated_at: datetime + temperature: Optional[float] + precipitation: Optional[float] + humidity: Optional[float] + wind_speed: Optional[float] + description: Optional[str] + source: str + hour: int \ No newline at end of file diff --git a/services/external/app/services/weather_service.py b/services/external/app/services/weather_service.py index f197d9e2..52eb7899 100644 --- a/services/external/app/services/weather_service.py +++ b/services/external/app/services/weather_service.py @@ -9,7 +9,7 @@ import structlog from app.models.weather import WeatherData, WeatherForecast from app.external.aemet import AEMETClient -from app.schemas.weather import WeatherDataResponse, WeatherForecastResponse +from app.schemas.weather import WeatherDataResponse, WeatherForecastResponse, HourlyForecastResponse from app.repositories.weather_repository import WeatherRepository logger = structlog.get_logger() @@ -79,6 +79,48 @@ class WeatherService: logger.error("Failed to get weather forecast", error=str(e), lat=latitude, lon=longitude) return [] + async def get_hourly_forecast(self, latitude: float, longitude: float, hours: int = 48) -> List[HourlyForecastResponse]: + """Get hourly weather forecast for location""" + try: + logger.debug("Getting hourly weather forecast", lat=latitude, lon=longitude, hours=hours) + hourly_data = await self.aemet_client.get_hourly_forecast(latitude, longitude, hours) + + if hourly_data: + logger.debug("Hourly forecast data received", count=len(hourly_data)) + # Validate each hourly forecast item before creating response + valid_forecasts = [] + for item in hourly_data: + try: + if isinstance(item, dict): + # Ensure required fields are present + hourly_item = { + "forecast_datetime": item.get("forecast_datetime", datetime.now()), + "generated_at": item.get("generated_at", datetime.now()), + "temperature": float(item.get("temperature", 15.0)), + "precipitation": float(item.get("precipitation", 0.0)), + "humidity": float(item.get("humidity", 50.0)), + "wind_speed": float(item.get("wind_speed", 10.0)), + "description": str(item.get("description", "Variable")), + "source": str(item.get("source", "unknown")), + "hour": int(item.get("hour", 0)) + } + valid_forecasts.append(HourlyForecastResponse(**hourly_item)) + else: + logger.warning("Invalid hourly forecast item type", item_type=type(item)) + except Exception as item_error: + logger.warning("Error processing hourly forecast item", error=str(item_error), item=item) + continue + + logger.debug("Valid hourly forecasts processed", count=len(valid_forecasts)) + return valid_forecasts + else: + logger.warning("No hourly forecast data received from AEMET client") + return [] + + except Exception as e: + logger.error("Failed to get hourly weather forecast", error=str(e), lat=latitude, lon=longitude) + return [] + async def get_historical_weather(self, latitude: float, longitude: float,