Improve the dahboard 4
This commit is contained in:
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<WeatherData | null>(null);
|
||||
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
|
||||
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
|
||||
const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([]);
|
||||
const [trafficForecast, setTrafficForecast] = useState<TrafficData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<HourlyForecast[]> => {
|
||||
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,
|
||||
|
||||
@@ -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<HourlyForecast[]> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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<WeatherContextProps> = ({ className = '' }) => {
|
||||
getCurrentWeather,
|
||||
getCurrentTraffic,
|
||||
getWeatherForecast,
|
||||
getHourlyWeatherForecast,
|
||||
isLoading,
|
||||
error
|
||||
} = useExternal();
|
||||
@@ -35,6 +38,8 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
|
||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
|
||||
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
|
||||
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
|
||||
const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([]);
|
||||
const [recommendations, setRecommendations] = useState<WeatherRecommendation[]>([]);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
@@ -49,10 +54,11 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ 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<WeatherContextProps> = ({ 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<WeatherContextProps> = ({ className = '' }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Business Recommendations - Most Important Section */}
|
||||
{recommendations.length > 0 && (
|
||||
<div className="mb-6 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-bold text-blue-800 mb-4 flex items-center">
|
||||
<TrendingUp className="h-5 w-5 mr-2" />
|
||||
Recomendaciones Meteorológicas para el Negocio
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{recommendations.slice(0, 4).map((rec) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className={`p-3 rounded-lg border-l-4 ${
|
||||
rec.priority === 'urgent' ? 'border-red-500 bg-red-50' :
|
||||
rec.priority === 'high' ? 'border-orange-500 bg-orange-50' :
|
||||
rec.priority === 'medium' ? 'border-yellow-500 bg-yellow-50' :
|
||||
'border-green-500 bg-green-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-1">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||
rec.type === 'production' ? 'bg-blue-100 text-blue-800' :
|
||||
rec.type === 'inventory' ? 'bg-green-100 text-green-800' :
|
||||
rec.type === 'marketing' ? 'bg-purple-100 text-purple-800' :
|
||||
rec.type === 'sales' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{rec.type.toUpperCase()}
|
||||
</span>
|
||||
<span className={`text-xs ml-2 px-1 py-0.5 rounded ${
|
||||
rec.priority === 'urgent' ? 'bg-red-200 text-red-800' :
|
||||
rec.priority === 'high' ? 'bg-orange-200 text-orange-800' :
|
||||
rec.priority === 'medium' ? 'bg-yellow-200 text-yellow-800' :
|
||||
'bg-green-200 text-green-800'
|
||||
}`}>
|
||||
{rec.priority.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<h5 className="font-semibold text-sm text-gray-900 mb-1">{rec.title}</h5>
|
||||
<p className="text-xs text-gray-700 mb-1">{rec.description}</p>
|
||||
<p className="text-xs font-medium text-gray-800 mb-1">
|
||||
<strong>Acción:</strong> {rec.action}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">{rec.impact}</p>
|
||||
{rec.businessMetrics?.expectedSalesChange && (
|
||||
<div className="mt-2 text-xs">
|
||||
<span className={`font-medium ${
|
||||
rec.businessMetrics.expectedSalesChange > 0 ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
Ventas: {rec.businessMetrics.expectedSalesChange > 0 ? '+' : ''}{rec.businessMetrics.expectedSalesChange}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right ml-3">
|
||||
<div className="text-xs text-gray-500">{rec.timeframe}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Confianza: {rec.confidence}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-blue-600 flex items-center">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Recomendaciones basadas en datos meteorológicos de AEMET en tiempo real
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hourly Forecast Preview */}
|
||||
{hourlyForecast.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="font-medium text-gray-800 mb-3">Pronóstico por Horas (Próximas 8 horas)</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{hourlyForecast.slice(0, 8).map((hourly, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-2 text-center">
|
||||
<div className="text-xs text-gray-600 mb-1">
|
||||
{new Date(hourly.forecast_datetime).getHours().toString().padStart(2, '0')}:00
|
||||
</div>
|
||||
<div className="flex justify-center mb-1">
|
||||
{getWeatherIcon(hourly.description, hourly.precipitation)}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{hourly.temperature.toFixed(0)}°
|
||||
</div>
|
||||
{hourly.precipitation > 0 && (
|
||||
<div className="text-xs text-blue-600">
|
||||
{hourly.precipitation.toFixed(1)}mm
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weather Forecast */}
|
||||
{weatherForecast.length > 0 && (
|
||||
<div>
|
||||
|
||||
449
frontend/src/services/weatherRecommendationService.ts
Normal file
449
frontend/src/services/weatherRecommendationService.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
43
frontend/src/types/weather.ts
Normal file
43
frontend/src/types/weather.ts
Normal file
@@ -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;
|
||||
}
|
||||
55
services/external/app/api/weather.py
vendored
55
services/external/app/api/weather.py
vendored
@@ -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"""
|
||||
|
||||
317
services/external/app/external/aemet.py
vendored
317
services/external/app/external/aemet.py
vendored
@@ -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()
|
||||
18
services/external/app/schemas/weather.py
vendored
18
services/external/app/schemas/weather.py
vendored
@@ -158,4 +158,20 @@ class HistoricalWeatherRequest(BaseModel):
|
||||
class WeatherForecastRequest(BaseModel):
|
||||
latitude: float
|
||||
longitude: float
|
||||
days: int
|
||||
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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user