Improve the dahboard with the weather info
This commit is contained in:
@@ -67,18 +67,8 @@ export class ExternalService {
|
|||||||
return response;
|
return response;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch weather from backend:', error);
|
console.error('Failed to fetch weather from AEMET API via backend:', error);
|
||||||
|
throw new Error(`Weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
||||||
// Fallback weather for Madrid (matching WeatherData schema)
|
|
||||||
return {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
temperature: 18,
|
|
||||||
description: 'Parcialmente nublado',
|
|
||||||
precipitation: 0,
|
|
||||||
humidity: 65,
|
|
||||||
wind_speed: 10,
|
|
||||||
source: 'fallback'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,8 +99,8 @@ export class ExternalService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch weather forecast:', error);
|
console.error('Failed to fetch weather forecast from AEMET API:', error);
|
||||||
return [];
|
throw new Error(`Weather forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +126,8 @@ export class ExternalService {
|
|||||||
// Return backend response directly (matches WeatherData interface)
|
// Return backend response directly (matches WeatherData interface)
|
||||||
return Array.isArray(response) ? response : response.data || [];
|
return Array.isArray(response) ? response : response.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch historical weather:', error);
|
console.error('Failed to fetch historical weather from AEMET API:', error);
|
||||||
return [];
|
throw new Error(`Historical weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,17 +150,8 @@ export class ExternalService {
|
|||||||
// Return backend response directly (matches TrafficData interface)
|
// Return backend response directly (matches TrafficData interface)
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch traffic data:', error);
|
console.error('Failed to fetch traffic data from external API:', error);
|
||||||
|
throw new Error(`Traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
||||||
// Fallback traffic data (matching TrafficData schema)
|
|
||||||
return {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
traffic_volume: 50,
|
|
||||||
pedestrian_count: 25,
|
|
||||||
congestion_level: 'medium',
|
|
||||||
average_speed: 30,
|
|
||||||
source: 'fallback'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +175,8 @@ export class ExternalService {
|
|||||||
// Return backend response directly (matches TrafficData interface)
|
// Return backend response directly (matches TrafficData interface)
|
||||||
return Array.isArray(response) ? response : response.data || [];
|
return Array.isArray(response) ? response : response.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch traffic forecast:', error);
|
console.error('Failed to fetch traffic forecast from external API:', error);
|
||||||
return [];
|
throw new Error(`Traffic forecast unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,8 +202,8 @@ export class ExternalService {
|
|||||||
// Return backend response directly (matches TrafficData interface)
|
// Return backend response directly (matches TrafficData interface)
|
||||||
return Array.isArray(response) ? response : response.data || [];
|
return Array.isArray(response) ? response : response.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch historical traffic:', error);
|
console.error('Failed to fetch historical traffic from external API:', error);
|
||||||
return [];
|
throw new Error(`Historical traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,11 +222,12 @@ export class ExternalService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Test weather service
|
// Test weather service (AEMET API)
|
||||||
await this.getCurrentWeather(tenantId, 40.4168, -3.7038); // Madrid coordinates
|
await this.getCurrentWeather(tenantId, 40.4168, -3.7038); // Madrid coordinates
|
||||||
results.weather = true;
|
results.weather = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Weather service connectivity test failed:', error);
|
console.warn('AEMET weather service connectivity test failed:', error);
|
||||||
|
results.weather = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -254,6 +236,7 @@ export class ExternalService {
|
|||||||
results.traffic = true;
|
results.traffic = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Traffic service connectivity test failed:', error);
|
console.warn('Traffic service connectivity test failed:', error);
|
||||||
|
results.traffic = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
results.overall = results.weather && results.traffic;
|
results.overall = results.weather && results.traffic;
|
||||||
|
|||||||
@@ -54,8 +54,18 @@ const OperationsLayout: React.FC = () => {
|
|||||||
icon: 'ShoppingCart',
|
icon: 'ShoppingCart',
|
||||||
children: [
|
children: [
|
||||||
{ id: 'daily-sales', label: 'Ventas Diarias', href: '/app/operations/sales/daily-sales' },
|
{ id: 'daily-sales', label: 'Ventas Diarias', href: '/app/operations/sales/daily-sales' },
|
||||||
{ id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' },
|
{ id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' }
|
||||||
{ id: 'pos-integration', label: bakeryType === 'individual' ? 'TPV' : 'Multi-TPV', href: '/app/operations/sales/pos-integration' }
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pos',
|
||||||
|
label: bakeryType === 'individual' ? 'TPV' : 'Sistema TPV',
|
||||||
|
href: '/app/operations/pos',
|
||||||
|
icon: 'CreditCard',
|
||||||
|
children: [
|
||||||
|
{ id: 'integrations', label: 'Integraciones', href: '/app/operations/pos/integrations' },
|
||||||
|
{ id: 'sync-status', label: 'Estado Sincronización', href: '/app/operations/pos/sync-status' },
|
||||||
|
{ id: 'transactions', label: 'Transacciones', href: '/app/operations/pos/transactions' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Key,
|
Key,
|
||||||
Webhook,
|
Webhook,
|
||||||
Sync,
|
RefreshCw,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -481,7 +481,7 @@ const POSConfigurationForm: React.FC<POSConfigurationFormProps> = ({
|
|||||||
{/* Sync Configuration */}
|
{/* Sync Configuration */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900 flex items-center">
|
<h3 className="text-lg font-medium text-gray-900 flex items-center">
|
||||||
<Sync className="h-5 w-5 mr-2" />
|
<RefreshCw className="h-5 w-5 mr-2" />
|
||||||
Synchronization Settings
|
Synchronization Settings
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|||||||
417
frontend/src/components/simple/WeatherContext.tsx
Normal file
417
frontend/src/components/simple/WeatherContext.tsx
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Cloud,
|
||||||
|
CloudRain,
|
||||||
|
Sun,
|
||||||
|
Thermometer,
|
||||||
|
Droplets,
|
||||||
|
Wind,
|
||||||
|
Gauge,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useExternal } from '../../api';
|
||||||
|
import { useTenantId } from '../../hooks/useTenantId';
|
||||||
|
import type { WeatherData, TrafficData, WeatherForecast } from '../../api/services/external.service';
|
||||||
|
import Card from '../ui/Card';
|
||||||
|
|
||||||
|
interface WeatherContextProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
|
||||||
|
const { tenantId } = useTenantId();
|
||||||
|
const {
|
||||||
|
getCurrentWeather,
|
||||||
|
getCurrentTraffic,
|
||||||
|
getWeatherForecast,
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
} = useExternal();
|
||||||
|
|
||||||
|
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
|
||||||
|
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
|
||||||
|
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Madrid coordinates (default for bakery location)
|
||||||
|
const latitude = 40.4168;
|
||||||
|
const longitude = -3.7038;
|
||||||
|
|
||||||
|
const loadWeatherContext = async () => {
|
||||||
|
if (!tenantId) return;
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load current weather data from AEMET
|
||||||
|
const [weather, traffic, forecast] = await Promise.allSettled([
|
||||||
|
getCurrentWeather(tenantId, latitude, longitude),
|
||||||
|
getCurrentTraffic(tenantId, latitude, longitude),
|
||||||
|
getWeatherForecast(tenantId, latitude, longitude, 3)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (weather.status === 'fulfilled') {
|
||||||
|
setWeatherData(weather.value);
|
||||||
|
} else {
|
||||||
|
console.warn('Weather data unavailable:', weather.reason);
|
||||||
|
setWeatherData(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (traffic.status === 'fulfilled') {
|
||||||
|
setTrafficData(traffic.value);
|
||||||
|
} else {
|
||||||
|
console.warn('Traffic data unavailable:', traffic.reason);
|
||||||
|
setTrafficData(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forecast.status === 'fulfilled') {
|
||||||
|
setWeatherForecast(forecast.value);
|
||||||
|
} else {
|
||||||
|
console.warn('Weather forecast unavailable:', forecast.reason);
|
||||||
|
setWeatherForecast([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load weather context:', error);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWeatherContext();
|
||||||
|
|
||||||
|
// Refresh data every 15 minutes
|
||||||
|
const interval = setInterval(loadWeatherContext, 15 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [tenantId]);
|
||||||
|
|
||||||
|
const getWeatherIcon = (description: string = '', precipitation: number = 0) => {
|
||||||
|
if (precipitation > 0) return <CloudRain className="h-6 w-6 text-blue-500" />;
|
||||||
|
if (description.toLowerCase().includes('nublado')) return <Cloud className="h-6 w-6 text-gray-500" />;
|
||||||
|
return <Sun className="h-6 w-6 text-yellow-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrafficImpact = () => {
|
||||||
|
if (!trafficData) return null;
|
||||||
|
|
||||||
|
const { traffic_volume = 50, pedestrian_count = 25, congestion_level = 'medium' } = trafficData;
|
||||||
|
|
||||||
|
let impactColor = 'text-green-600';
|
||||||
|
let impactText = 'Favorable para las ventas';
|
||||||
|
let impactIcon = <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||||
|
|
||||||
|
if (congestion_level === 'high' || traffic_volume > 80) {
|
||||||
|
impactColor = 'text-red-600';
|
||||||
|
impactText = 'Tráfico alto - posible reducción clientes';
|
||||||
|
impactIcon = <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||||
|
} else if (congestion_level === 'low' || traffic_volume < 30) {
|
||||||
|
impactColor = 'text-yellow-600';
|
||||||
|
impactText = 'Tráfico bajo - ventas pueden reducirse';
|
||||||
|
impactIcon = <AlertTriangle className="h-4 w-4 text-yellow-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { impactColor, impactText, impactIcon, traffic_volume, pedestrian_count };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWeatherImpact = () => {
|
||||||
|
if (!weatherData) return null;
|
||||||
|
|
||||||
|
const impacts = [];
|
||||||
|
|
||||||
|
if (weatherData.precipitation && weatherData.precipitation > 0) {
|
||||||
|
if (weatherData.precipitation > 10) {
|
||||||
|
impacts.push('☔ Lluvia fuerte: -25% clientes esperados');
|
||||||
|
} else {
|
||||||
|
impacts.push('🌦️ Lluvia ligera: -15% clientes esperados');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weatherData.temperature && weatherData.temperature < 5) {
|
||||||
|
impacts.push('🥶 Frío intenso: +20% bebidas calientes');
|
||||||
|
} else if (weatherData.temperature && weatherData.temperature > 25) {
|
||||||
|
impacts.push('☀️ Tiempo cálido: +15% bebidas frías');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weatherData.wind_speed && weatherData.wind_speed > 30) {
|
||||||
|
impacts.push('💨 Viento fuerte: posible reducción tránsito');
|
||||||
|
}
|
||||||
|
|
||||||
|
return impacts;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading && !weatherData) {
|
||||||
|
return (
|
||||||
|
<Card className={`p-6 ${className}`}>
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<span className="ml-3 text-gray-600">Cargando datos de AEMET...</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !weatherData) {
|
||||||
|
return (
|
||||||
|
<Card className={`p-6 border-red-200 bg-red-50 ${className}`}>
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-red-800 mb-2">Datos Meteorológicos No Disponibles</h3>
|
||||||
|
<p className="text-red-700 text-sm mb-4">
|
||||||
|
No se pueden obtener datos de AEMET en este momento. Los pronósticos pueden verse afectados.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={loadWeatherContext}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="px-4 py-2 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRefreshing ? 'Reintentando...' : 'Reintentar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning for synthetic data
|
||||||
|
if (weatherData && weatherData.source === 'synthetic') {
|
||||||
|
return (
|
||||||
|
<Card className={`p-6 border-yellow-200 bg-yellow-50 ${className}`}>
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-yellow-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-yellow-800 mb-2">⚠️ Usando Datos de Prueba</h3>
|
||||||
|
<p className="text-yellow-700 text-sm mb-4">
|
||||||
|
El sistema está usando datos meteorológicos sintéticos. Para obtener predicciones precisas,
|
||||||
|
es necesario configurar la integración con AEMET.
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-yellow-600 mb-4">
|
||||||
|
<strong>Datos actuales (sintéticos):</strong><br/>
|
||||||
|
Temperatura: {weatherData.temperature}°C |
|
||||||
|
Humedad: {weatherData.humidity}% |
|
||||||
|
Viento: {weatherData.wind_speed} km/h
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadWeatherContext}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="px-4 py-2 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRefreshing ? 'Verificando AEMET...' : 'Verificar Conexión AEMET'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trafficImpact = getTrafficImpact();
|
||||||
|
const weatherImpacts = getWeatherImpact();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`p-6 border-l-4 border-l-blue-500 ${className}`}>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 flex items-center">
|
||||||
|
{weatherData && getWeatherIcon(weatherData.description, weatherData.precipitation)}
|
||||||
|
<span className="ml-3">Clima & Contexto Operacional</span>
|
||||||
|
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
|
||||||
|
{weatherData?.source === 'aemet' ? 'AEMET Oficial' : weatherData?.source || 'Fuente Externa'}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center text-xs text-gray-500">
|
||||||
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
{lastUpdated ? `${lastUpdated.toLocaleTimeString()}` : 'No actualizado'}
|
||||||
|
{isRefreshing && (
|
||||||
|
<div className="ml-2 animate-spin rounded-full h-3 w-3 border-b border-gray-400"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Weather Section */}
|
||||||
|
{weatherData && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="font-medium text-gray-800 mb-3">Condiciones Actuales (AEMET)</h4>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Thermometer className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="text-xs text-blue-600">Temperatura</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-lg font-semibold text-blue-900">
|
||||||
|
{weatherData.temperature?.toFixed(1) || '--'}°C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-cyan-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Droplets className="h-4 w-4 text-cyan-600" />
|
||||||
|
<span className="text-xs text-cyan-600">Humedad</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-lg font-semibold text-cyan-900">
|
||||||
|
{weatherData.humidity?.toFixed(0) || '--'}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-green-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Wind className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-xs text-green-600">Viento</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-lg font-semibold text-green-900">
|
||||||
|
{weatherData.wind_speed?.toFixed(0) || '--'} km/h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-purple-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Gauge className="h-4 w-4 text-purple-600" />
|
||||||
|
<span className="text-xs text-purple-600">Presión</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-lg font-semibold text-purple-900">
|
||||||
|
{weatherData.pressure?.toFixed(0) || '--'} hPa
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weatherData.description && (
|
||||||
|
<div className="mt-3 text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
|
||||||
|
<strong>Descripción:</strong> {weatherData.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{weatherData.precipitation && weatherData.precipitation > 0 && (
|
||||||
|
<div className="mt-2 text-sm text-blue-600 bg-blue-50 rounded-lg p-3">
|
||||||
|
<strong>Precipitación:</strong> {weatherData.precipitation.toFixed(1)} mm
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Traffic Context Section */}
|
||||||
|
{trafficData && trafficImpact && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="font-medium text-gray-800 mb-3">Contexto de Tráfico</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||||
|
<div className="bg-orange-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<TrendingUp className="h-4 w-4 text-orange-600" />
|
||||||
|
<span className="text-xs text-orange-600">Volumen</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-lg font-semibold text-orange-900">
|
||||||
|
{trafficImpact.traffic_volume}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-indigo-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Users className="h-4 w-4 text-indigo-600" />
|
||||||
|
<span className="text-xs text-indigo-600">Peatones</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-lg font-semibold text-indigo-900">
|
||||||
|
{trafficImpact.pedestrian_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center text-sm ${trafficImpact.impactColor} bg-gray-50 rounded-lg p-3`}>
|
||||||
|
{trafficImpact.impactIcon}
|
||||||
|
<span className="ml-2">{trafficImpact.impactText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Business Impact Analysis - Most Important Section */}
|
||||||
|
{weatherImpacts && weatherImpacts.length > 0 && (
|
||||||
|
<div className="mb-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-amber-800 mb-3 flex items-center">
|
||||||
|
<TrendingUp className="h-5 w-5 mr-2" />
|
||||||
|
Impacto Operacional Detectado
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{weatherImpacts.map((impact, index) => (
|
||||||
|
<div key={index} className="flex items-center text-sm font-medium text-amber-700 bg-amber-100 rounded-lg p-3">
|
||||||
|
<span className="text-lg mr-3">{impact.split(' ')[0]}</span>
|
||||||
|
<span>{impact.substring(impact.indexOf(' ') + 1)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-amber-600 flex items-center">
|
||||||
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
|
Recomendaciones aplicadas automáticamente en pronósticos de demanda
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Weather Forecast */}
|
||||||
|
{weatherForecast.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-800 mb-3">Pronóstico (Próximos Días)</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{weatherForecast.slice(0, 3).map((forecast, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getWeatherIcon(forecast.description, forecast.precipitation)}
|
||||||
|
<div className="ml-3">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{new Date(forecast.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">{forecast.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-semibold text-gray-900">
|
||||||
|
{forecast.temperature_max}° / {forecast.temperature_min}°
|
||||||
|
</div>
|
||||||
|
{forecast.precipitation > 0 && (
|
||||||
|
<div className="text-xs text-blue-600">{forecast.precipitation}mm</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Source Info & Controls */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
<strong>Fuente:</strong> {weatherData?.source === 'aemet' ? '🇪🇸 AEMET (Agencia Estatal de Meteorología)' :
|
||||||
|
weatherData?.source === 'synthetic' ? '⚠️ Datos de Prueba' :
|
||||||
|
weatherData?.source || 'No disponible'}
|
||||||
|
</span>
|
||||||
|
{weatherData?.date && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
<strong>Fecha datos:</strong> {new Date(weatherData.date).toLocaleString('es-ES')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadWeatherContext}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="px-3 py-1 text-xs bg-blue-100 text-blue-700 hover:bg-blue-200 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRefreshing ? '⟳ Actualizando...' : '↻ Actualizar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WeatherContext;
|
||||||
7
frontend/src/components/simple/index.ts
Normal file
7
frontend/src/components/simple/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { default as CriticalAlerts } from './CriticalAlerts';
|
||||||
|
export { default as OrderSuggestions } from './OrderSuggestions';
|
||||||
|
export { default as QuickActions } from './QuickActions';
|
||||||
|
export { default as QuickOverview } from './QuickOverview';
|
||||||
|
export { default as TodayProduction } from './TodayProduction';
|
||||||
|
export { default as TodayRevenue } from './TodayRevenue';
|
||||||
|
export { default as WeatherContext } from './WeatherContext';
|
||||||
@@ -9,9 +9,14 @@ import { useTenantId } from './useTenantId';
|
|||||||
|
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
weather: {
|
weather: {
|
||||||
temperature: number;
|
temperature?: number;
|
||||||
description: string;
|
description?: string;
|
||||||
precipitation: number;
|
precipitation?: number;
|
||||||
|
humidity?: number;
|
||||||
|
wind_speed?: number;
|
||||||
|
pressure?: number;
|
||||||
|
source: string;
|
||||||
|
date: string;
|
||||||
} | null;
|
} | null;
|
||||||
todayForecasts: Array<{
|
todayForecasts: Array<{
|
||||||
product: string;
|
product: string;
|
||||||
@@ -91,18 +96,16 @@ export const useDashboard = () => {
|
|||||||
try {
|
try {
|
||||||
// 1. Get available products from inventory service
|
// 1. Get available products from inventory service
|
||||||
const products = await getProductsList(tenantId);
|
const products = await getProductsList(tenantId);
|
||||||
// 2. Get weather data (Madrid coordinates)
|
// 2. Get complete weather data from AEMET (Madrid coordinates)
|
||||||
let weather = null;
|
let weather = null;
|
||||||
try {
|
try {
|
||||||
weather = await getCurrentWeather(tenantId, 40.4168, -3.7038);
|
const weatherResponse = await getCurrentWeather(tenantId, 40.4168, -3.7038);
|
||||||
|
// Use the full WeatherData interface instead of limited subset
|
||||||
|
weather = weatherResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch weather:', error);
|
console.error('AEMET weather data unavailable for dashboard:', error);
|
||||||
// Fallback weather
|
// No fallback - weather remains null if AEMET API fails
|
||||||
weather = {
|
weather = null;
|
||||||
temperature: 18,
|
|
||||||
description: 'Parcialmente nublado',
|
|
||||||
precipitation: 0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate forecasts for each product
|
// 3. Generate forecasts for each product
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Real API hook for Order Suggestions using backend data
|
// Real API hook for Order Suggestions using backend data
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useSales, useExternal, useForecast, useInventoryProducts } from '../api';
|
import { useExternal, useForecast, useInventoryProducts } from '../api';
|
||||||
import { useTenantId } from './useTenantId';
|
import { useTenantId } from './useTenantId';
|
||||||
import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions';
|
import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions';
|
||||||
|
|
||||||
@@ -44,10 +44,6 @@ export const useOrderSuggestions = (providedTenantId?: string | null) => {
|
|||||||
const tenantId = providedTenantId !== undefined ? providedTenantId : hookTenantId;
|
const tenantId = providedTenantId !== undefined ? providedTenantId : hookTenantId;
|
||||||
|
|
||||||
console.log('🏢 OrderSuggestions: Tenant info:', { tenantId, tenantLoading, tenantError });
|
console.log('🏢 OrderSuggestions: Tenant info:', { tenantId, tenantLoading, tenantError });
|
||||||
const {
|
|
||||||
getSalesAnalytics,
|
|
||||||
getDashboardStats
|
|
||||||
} = useSales();
|
|
||||||
const {
|
const {
|
||||||
getProductsList
|
getProductsList
|
||||||
} = useInventoryProducts();
|
} = useInventoryProducts();
|
||||||
@@ -55,159 +51,213 @@ export const useOrderSuggestions = (providedTenantId?: string | null) => {
|
|||||||
getCurrentWeather
|
getCurrentWeather
|
||||||
} = useExternal();
|
} = useExternal();
|
||||||
const {
|
const {
|
||||||
createSingleForecast,
|
createSingleForecast
|
||||||
getQuickForecasts,
|
|
||||||
getForecastAlerts
|
|
||||||
} = useForecast();
|
} = useForecast();
|
||||||
|
|
||||||
// Generate daily order suggestions based on real forecast data
|
// Generate daily order suggestions - creates next day forecasts for all products
|
||||||
const generateDailyOrderSuggestions = useCallback(async (): Promise<DailyOrderItem[]> => {
|
const generateDailyOrderSuggestions = useCallback(async (): Promise<DailyOrderItem[]> => {
|
||||||
if (!tenantId) return [];
|
if (!tenantId) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('📊 OrderSuggestions: Generating daily suggestions for tenant:', tenantId);
|
console.log('📊 OrderSuggestions: Generating daily suggestions for tenant:', tenantId);
|
||||||
|
|
||||||
// Get products list from backend
|
// Get all products from backend
|
||||||
const productsList = await getProductsList(tenantId);
|
const productsList = await getProductsList(tenantId);
|
||||||
const products = productsList.map(p => p.name);
|
console.log('📋 OrderSuggestions: Products list:', productsList);
|
||||||
console.log('📋 OrderSuggestions: Products list:', products);
|
|
||||||
|
|
||||||
// Filter for daily bakery products (case insensitive)
|
// Filter for daily products (bakery items that need daily forecasting)
|
||||||
const dailyProductKeywords = ['pan', 'baguette', 'croissant', 'magdalena'];
|
const dailyProductKeywords = ['pan', 'baguette', 'croissant', 'magdalena', 'tarta', 'bollo', 'empanada'];
|
||||||
const dailyProducts = products.filter(p =>
|
const dailyProducts = productsList.filter(p =>
|
||||||
dailyProductKeywords.some(keyword =>
|
dailyProductKeywords.some(keyword =>
|
||||||
p.toLowerCase().includes(keyword.toLowerCase())
|
p.name.toLowerCase().includes(keyword.toLowerCase())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
console.log('🥖 OrderSuggestions: Daily products:', dailyProducts);
|
console.log('🥖 OrderSuggestions: Daily products:', dailyProducts);
|
||||||
|
|
||||||
// Get quick forecasts for these products
|
// Create individual forecasts for each daily product for next day
|
||||||
const quickForecasts = await getQuickForecasts(tenantId);
|
|
||||||
console.log('🔮 OrderSuggestions: Quick forecasts:', quickForecasts);
|
|
||||||
|
|
||||||
// Get weather data to determine urgency
|
|
||||||
const weather = await getCurrentWeather(tenantId, 40.4168, -3.7038);
|
|
||||||
console.log('🌤️ OrderSuggestions: Weather data:', weather);
|
|
||||||
|
|
||||||
const suggestions: DailyOrderItem[] = [];
|
const suggestions: DailyOrderItem[] = [];
|
||||||
console.log('🔄 OrderSuggestions: Processing daily products:', dailyProducts);
|
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Get weather data to determine urgency factors
|
||||||
|
let weather = null;
|
||||||
|
try {
|
||||||
|
weather = await getCurrentWeather(tenantId, 40.4168, -3.7038);
|
||||||
|
console.log('🌤️ OrderSuggestions: Weather data:', weather);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ OrderSuggestions: AEMET weather data unavailable, continuing without weather factors:', error);
|
||||||
|
weather = null;
|
||||||
|
}
|
||||||
|
|
||||||
for (const product of dailyProducts) {
|
for (const product of dailyProducts) {
|
||||||
console.log('🔄 OrderSuggestions: Processing product:', product);
|
console.log('🔄 OrderSuggestions: Creating forecast for product:', product.name);
|
||||||
// Find forecast for this product
|
|
||||||
const forecast = quickForecasts.find(f =>
|
|
||||||
f.product_name === product || f.inventory_product_id === product
|
|
||||||
);
|
|
||||||
|
|
||||||
if (forecast) {
|
try {
|
||||||
// Calculate suggested quantity based on prediction
|
// Create single forecast for next day for this specific product
|
||||||
const suggestedQuantity = Math.max(forecast.next_day_prediction, 10);
|
const forecastRequest = {
|
||||||
|
inventory_product_id: product.inventory_product_id,
|
||||||
// Determine urgency based on confidence and trend
|
forecast_date: tomorrow,
|
||||||
let urgency: 'high' | 'medium' | 'low' = 'medium';
|
forecast_days: 1,
|
||||||
if (forecast.confidence_score > 0.9 && forecast.trend_direction === 'up') {
|
location: 'Madrid',
|
||||||
urgency = 'high';
|
include_external_factors: true,
|
||||||
} else if (forecast.confidence_score < 0.7) {
|
confidence_intervals: true
|
||||||
urgency = 'low';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate reason based on forecast data
|
|
||||||
let reason = `Predicción: ${forecast.next_day_prediction} unidades`;
|
|
||||||
if (forecast.trend_direction === 'up') {
|
|
||||||
reason += ' (tendencia al alza)';
|
|
||||||
}
|
|
||||||
if (weather && weather.precipitation > 0) {
|
|
||||||
reason += ', lluvia prevista';
|
|
||||||
urgency = urgency === 'low' ? 'medium' : 'high';
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderItem: DailyOrderItem = {
|
|
||||||
id: `daily-${product.toLowerCase().replace(/\s+/g, '-')}`,
|
|
||||||
product,
|
|
||||||
emoji: getProductEmoji(product),
|
|
||||||
suggestedQuantity: Math.round(suggestedQuantity),
|
|
||||||
currentQuantity: Math.round(suggestedQuantity * 0.2), // Assume 20% current stock
|
|
||||||
unit: 'unidades',
|
|
||||||
urgency,
|
|
||||||
reason,
|
|
||||||
confidence: Math.round(forecast.confidence_score * 100),
|
|
||||||
supplier: SUPPLIERS[product] || 'Proveedor General',
|
|
||||||
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 2.5) * 100) / 100,
|
|
||||||
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const forecasts = await createSingleForecast(tenantId, forecastRequest);
|
||||||
|
|
||||||
|
if (forecasts.length > 0) {
|
||||||
|
const forecast = forecasts[0];
|
||||||
|
const suggestedQuantity = Math.max(Math.round(forecast.predicted_demand), 5);
|
||||||
|
|
||||||
|
// Determine urgency based on confidence and external factors
|
||||||
|
let urgency: 'high' | 'medium' | 'low' = 'medium';
|
||||||
|
const confidence = forecast.confidence_level || 0.8;
|
||||||
|
|
||||||
|
if (confidence > 0.9) {
|
||||||
|
urgency = 'low'; // High confidence = low urgency
|
||||||
|
} else if (confidence < 0.6) {
|
||||||
|
urgency = 'high'; // Low confidence = high urgency, need more buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust based on weather
|
||||||
|
if (weather && weather.precipitation > 0) {
|
||||||
|
urgency = urgency === 'low' ? 'medium' : 'high';
|
||||||
|
}
|
||||||
|
|
||||||
suggestions.push(orderItem);
|
// Generate reason based on forecast data
|
||||||
console.log('➕ OrderSuggestions: Added daily suggestion:', orderItem);
|
let reason = `Predicción: ${suggestedQuantity} unidades (demanda para mañana)`;
|
||||||
|
if (weather && weather.precipitation > 0) {
|
||||||
|
reason += ', lluvia prevista';
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderItem: DailyOrderItem = {
|
||||||
|
id: `daily-${product.name.toLowerCase().replace(/\s+/g, '-')}`,
|
||||||
|
product: product.name,
|
||||||
|
emoji: getProductEmoji(product.name),
|
||||||
|
suggestedQuantity,
|
||||||
|
currentQuantity: Math.round(suggestedQuantity * (0.1 + Math.random() * 0.3)), // Random current stock 10-40%
|
||||||
|
unit: getProductUnit(product.name),
|
||||||
|
urgency,
|
||||||
|
reason,
|
||||||
|
confidence: Math.round(confidence * 100),
|
||||||
|
supplier: SUPPLIERS[product.name] || 'Proveedor General',
|
||||||
|
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product.name] || 2.5) * 100) / 100,
|
||||||
|
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
|
||||||
|
suggestions.push(orderItem);
|
||||||
|
console.log('➕ OrderSuggestions: Added daily suggestion:', orderItem);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ OrderSuggestions: Failed to create forecast for ${product.name}:`, error);
|
||||||
|
// Continue with other products even if one fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎯 OrderSuggestions: Final daily suggestions:', suggestions);
|
console.log('🎯 OrderSuggestions: Final daily suggestions:', suggestions);
|
||||||
return suggestions;
|
return suggestions.sort((a, b) => {
|
||||||
|
// Sort by urgency (high first) then by suggested quantity
|
||||||
|
const urgencyOrder = { high: 3, medium: 2, low: 1 };
|
||||||
|
if (urgencyOrder[a.urgency] !== urgencyOrder[b.urgency]) {
|
||||||
|
return urgencyOrder[b.urgency] - urgencyOrder[a.urgency];
|
||||||
|
}
|
||||||
|
return b.suggestedQuantity - a.suggestedQuantity;
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ OrderSuggestions: Error in generateDailyOrderSuggestions:', error);
|
console.error('❌ OrderSuggestions: Error in generateDailyOrderSuggestions:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [tenantId, getProductsList, getQuickForecasts, getCurrentWeather]);
|
}, [tenantId, getProductsList, createSingleForecast, getCurrentWeather]);
|
||||||
|
|
||||||
// Generate weekly order suggestions based on sales analytics
|
// Generate weekly order suggestions - creates 7-day forecasts for weekly order products
|
||||||
const generateWeeklyOrderSuggestions = useCallback(async (): Promise<WeeklyOrderItem[]> => {
|
const generateWeeklyOrderSuggestions = useCallback(async (): Promise<WeeklyOrderItem[]> => {
|
||||||
if (!tenantId) return [];
|
if (!tenantId) return [];
|
||||||
|
|
||||||
console.log('📊 OrderSuggestions: Generating weekly suggestions for tenant:', tenantId);
|
try {
|
||||||
|
console.log('📊 OrderSuggestions: Generating weekly suggestions for tenant:', tenantId);
|
||||||
|
|
||||||
// Get sales analytics for the past month
|
// Get all products from backend
|
||||||
const endDate = new Date().toISOString();
|
const productsList = await getProductsList(tenantId);
|
||||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
console.log('📋 OrderSuggestions: Products list for weekly:', productsList);
|
||||||
|
|
||||||
const analytics = await getSalesAnalytics(tenantId, startDate, endDate);
|
// Filter for weekly products (ingredients, supplies, drinks that need weekly forecasting)
|
||||||
console.log('📈 OrderSuggestions: Sales analytics:', analytics);
|
const weeklyProductKeywords = ['café', 'leche', 'mantequilla', 'vaso', 'servilleta', 'bolsa', 'bebida', 'refresco', 'agua'];
|
||||||
|
const weeklyProducts = productsList.filter(p =>
|
||||||
// Weekly products (ingredients and supplies)
|
weeklyProductKeywords.some(keyword =>
|
||||||
const weeklyProducts = [
|
p.name.toLowerCase().includes(keyword.toLowerCase())
|
||||||
'Café en Grano',
|
)
|
||||||
'Leche Entera',
|
);
|
||||||
'Mantequilla',
|
console.log('📦 OrderSuggestions: Weekly products:', weeklyProducts);
|
||||||
'Vasos de Café',
|
|
||||||
'Servilletas',
|
|
||||||
'Bolsas papel'
|
|
||||||
];
|
|
||||||
|
|
||||||
const suggestions: WeeklyOrderItem[] = [];
|
const suggestions: WeeklyOrderItem[] = [];
|
||||||
|
const startDate = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
for (const product of weeklyProducts) {
|
for (const product of weeklyProducts) {
|
||||||
// Calculate weekly consumption based on analytics
|
console.log('🔄 OrderSuggestions: Creating 7-day forecast for product:', product.name);
|
||||||
const weeklyConsumption = calculateWeeklyConsumption(product, analytics);
|
|
||||||
const currentStock = Math.round(weeklyConsumption * (0.3 + Math.random() * 0.4)); // Random stock between 30-70% of weekly need
|
try {
|
||||||
const stockDays = Math.max(1, Math.round((currentStock / weeklyConsumption) * 7));
|
// Create single forecast for 7 days for this specific product
|
||||||
|
const forecastRequest = {
|
||||||
|
inventory_product_id: product.inventory_product_id,
|
||||||
|
forecast_date: startDate,
|
||||||
|
forecast_days: 7,
|
||||||
|
location: 'Madrid',
|
||||||
|
include_external_factors: true,
|
||||||
|
confidence_intervals: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const forecasts = await createSingleForecast(tenantId, forecastRequest);
|
||||||
|
|
||||||
|
if (forecasts.length > 0) {
|
||||||
|
// Calculate total weekly demand from all forecast days
|
||||||
|
const totalWeeklyDemand = forecasts.reduce((sum, forecast) => sum + forecast.predicted_demand, 0);
|
||||||
|
const averageConfidence = forecasts.reduce((sum, forecast) => sum + (forecast.confidence_level || 0.8), 0) / forecasts.length;
|
||||||
|
|
||||||
|
// Determine frequency based on product type
|
||||||
|
const highFrequencyKeywords = ['café', 'leche', 'mantequilla', 'bebida'];
|
||||||
|
const frequency: 'weekly' | 'biweekly' =
|
||||||
|
highFrequencyKeywords.some(keyword => product.name.toLowerCase().includes(keyword)) ? 'weekly' : 'biweekly';
|
||||||
|
|
||||||
|
// Calculate suggested quantity with buffer
|
||||||
|
const suggestedQuantity = frequency === 'weekly' ?
|
||||||
|
Math.round(totalWeeklyDemand * 1.15) : // 15% buffer for weekly
|
||||||
|
Math.round(totalWeeklyDemand * 2.3); // 2 weeks + 15% buffer
|
||||||
|
|
||||||
|
// Simulate current stock and calculate stock days
|
||||||
|
const dailyConsumption = totalWeeklyDemand / 7;
|
||||||
|
const currentStock = Math.round(dailyConsumption * (2 + Math.random() * 8)); // 2-10 days worth
|
||||||
|
const stockDays = Math.max(1, Math.round(currentStock / dailyConsumption));
|
||||||
|
|
||||||
// Determine frequency
|
const orderItem: WeeklyOrderItem = {
|
||||||
const frequency: 'weekly' | 'biweekly' =
|
id: `weekly-${product.name.toLowerCase().replace(/\s+/g, '-')}`,
|
||||||
['Café en Grano', 'Leche Entera', 'Mantequilla'].includes(product) ? 'weekly' : 'biweekly';
|
product: product.name,
|
||||||
|
emoji: getProductEmoji(product.name),
|
||||||
|
suggestedQuantity,
|
||||||
|
currentStock,
|
||||||
|
unit: getProductUnit(product.name),
|
||||||
|
frequency,
|
||||||
|
nextOrderDate: getNextOrderDate(frequency, stockDays),
|
||||||
|
supplier: SUPPLIERS[product.name] || 'Proveedor General',
|
||||||
|
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product.name] || 1.0) * 100) / 100,
|
||||||
|
stockDays,
|
||||||
|
confidence: Math.round(averageConfidence * 100)
|
||||||
|
};
|
||||||
|
|
||||||
const suggestedQuantity = frequency === 'weekly' ?
|
suggestions.push(orderItem);
|
||||||
Math.round(weeklyConsumption * 1.1) : // 10% buffer for weekly
|
console.log('➕ OrderSuggestions: Added weekly suggestion:', orderItem);
|
||||||
Math.round(weeklyConsumption * 2.2); // 2 weeks + 10% buffer
|
}
|
||||||
|
} catch (error) {
|
||||||
const orderItem: WeeklyOrderItem = {
|
console.warn(`⚠️ OrderSuggestions: Failed to create weekly forecast for ${product.name}:`, error);
|
||||||
id: `weekly-${product.toLowerCase().replace(/\s+/g, '-')}`,
|
// Continue with other products even if one fails
|
||||||
product,
|
}
|
||||||
emoji: getProductEmoji(product),
|
|
||||||
suggestedQuantity,
|
|
||||||
currentStock,
|
|
||||||
unit: getProductUnit(product),
|
|
||||||
frequency,
|
|
||||||
nextOrderDate: getNextOrderDate(frequency, stockDays),
|
|
||||||
supplier: SUPPLIERS[product] || 'Proveedor General',
|
|
||||||
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 1.0) * 100) / 100,
|
|
||||||
stockDays,
|
|
||||||
confidence: stockDays <= 2 ? 95 : stockDays <= 5 ? 85 : 75
|
|
||||||
};
|
|
||||||
|
|
||||||
suggestions.push(orderItem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency
|
console.log('🎯 OrderSuggestions: Final weekly suggestions:', suggestions);
|
||||||
}, [tenantId, getSalesAnalytics]);
|
return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency (lowest stock days first)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ OrderSuggestions: Error in generateWeeklyOrderSuggestions:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [tenantId, getProductsList, createSingleForecast]);
|
||||||
|
|
||||||
// Load order suggestions
|
// Load order suggestions
|
||||||
const loadOrderSuggestions = useCallback(async () => {
|
const loadOrderSuggestions = useCallback(async () => {
|
||||||
@@ -282,7 +332,13 @@ function getProductEmoji(product: string): string {
|
|||||||
'Mantequilla': '🧈',
|
'Mantequilla': '🧈',
|
||||||
'Vasos de Café': '🥤',
|
'Vasos de Café': '🥤',
|
||||||
'Servilletas': '🧻',
|
'Servilletas': '🧻',
|
||||||
'Bolsas papel': '🛍️'
|
'Bolsas papel': '🛍️',
|
||||||
|
'Bebida': '🥤',
|
||||||
|
'Refresco': '🥤',
|
||||||
|
'Agua': '💧',
|
||||||
|
'Tarta': '🎂',
|
||||||
|
'Bollo': '🥖',
|
||||||
|
'Empanada': '🥟'
|
||||||
};
|
};
|
||||||
return emojiMap[product] || '📦';
|
return emojiMap[product] || '📦';
|
||||||
}
|
}
|
||||||
@@ -298,25 +354,17 @@ function getProductUnit(product: string): string {
|
|||||||
'Mantequilla': 'kg',
|
'Mantequilla': 'kg',
|
||||||
'Vasos de Café': 'unidades',
|
'Vasos de Café': 'unidades',
|
||||||
'Servilletas': 'paquetes',
|
'Servilletas': 'paquetes',
|
||||||
'Bolsas papel': 'unidades'
|
'Bolsas papel': 'unidades',
|
||||||
|
'Bebida': 'litros',
|
||||||
|
'Refresco': 'litros',
|
||||||
|
'Agua': 'litros',
|
||||||
|
'Tarta': 'unidades',
|
||||||
|
'Bollo': 'unidades',
|
||||||
|
'Empanada': 'unidades'
|
||||||
};
|
};
|
||||||
return unitMap[product] || 'unidades';
|
return unitMap[product] || 'unidades';
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateWeeklyConsumption(product: string, analytics: any): number {
|
|
||||||
// This would ideally come from sales analytics
|
|
||||||
// For now, use realistic estimates based on product type
|
|
||||||
const weeklyEstimates: Record<string, number> = {
|
|
||||||
'Café en Grano': 5, // 5kg per week
|
|
||||||
'Leche Entera': 25, // 25L per week
|
|
||||||
'Mantequilla': 3, // 3kg per week
|
|
||||||
'Vasos de Café': 500, // 500 cups per week
|
|
||||||
'Servilletas': 10, // 10 packs per week
|
|
||||||
'Bolsas papel': 200 // 200 bags per week
|
|
||||||
};
|
|
||||||
|
|
||||||
return weeklyEstimates[product] || 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): string {
|
function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): string {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -333,97 +381,3 @@ function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number):
|
|||||||
return nextDate.toISOString().split('T')[0];
|
return nextDate.toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock data functions for when tenant ID is not available
|
|
||||||
function getMockDailyOrders(): DailyOrderItem[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'daily-pan-molde',
|
|
||||||
product: 'Pan de Molde',
|
|
||||||
emoji: '🍞',
|
|
||||||
suggestedQuantity: 25,
|
|
||||||
currentQuantity: 5,
|
|
||||||
unit: 'unidades',
|
|
||||||
urgency: 'medium' as const,
|
|
||||||
reason: 'Predicción: 25 unidades (tendencia estable)',
|
|
||||||
confidence: 85,
|
|
||||||
supplier: 'Panadería Central Madrid',
|
|
||||||
estimatedCost: 45.00,
|
|
||||||
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'daily-baguettes',
|
|
||||||
product: 'Baguettes',
|
|
||||||
emoji: '🥖',
|
|
||||||
suggestedQuantity: 20,
|
|
||||||
currentQuantity: 3,
|
|
||||||
unit: 'unidades',
|
|
||||||
urgency: 'high' as const,
|
|
||||||
reason: 'Predicción: 20 unidades (tendencia al alza)',
|
|
||||||
confidence: 92,
|
|
||||||
supplier: 'Panadería Central Madrid',
|
|
||||||
estimatedCost: 56.00,
|
|
||||||
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'daily-croissants',
|
|
||||||
product: 'Croissants',
|
|
||||||
emoji: '🥐',
|
|
||||||
suggestedQuantity: 15,
|
|
||||||
currentQuantity: 8,
|
|
||||||
unit: 'unidades',
|
|
||||||
urgency: 'low' as const,
|
|
||||||
reason: 'Predicción: 15 unidades (demanda regular)',
|
|
||||||
confidence: 78,
|
|
||||||
supplier: 'Panadería Central Madrid',
|
|
||||||
estimatedCost: 37.50,
|
|
||||||
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMockWeeklyOrders(): WeeklyOrderItem[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'weekly-cafe-grano',
|
|
||||||
product: 'Café en Grano',
|
|
||||||
emoji: '☕',
|
|
||||||
suggestedQuantity: 5,
|
|
||||||
currentStock: 2,
|
|
||||||
unit: 'kg',
|
|
||||||
frequency: 'weekly' as const,
|
|
||||||
nextOrderDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
||||||
supplier: 'Cafés Premium',
|
|
||||||
estimatedCost: 87.50,
|
|
||||||
stockDays: 3,
|
|
||||||
confidence: 95
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'weekly-leche-entera',
|
|
||||||
product: 'Leche Entera',
|
|
||||||
emoji: '🥛',
|
|
||||||
suggestedQuantity: 25,
|
|
||||||
currentStock: 15,
|
|
||||||
unit: 'litros',
|
|
||||||
frequency: 'weekly' as const,
|
|
||||||
nextOrderDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
||||||
supplier: 'Lácteos Frescos SA',
|
|
||||||
estimatedCost: 23.75,
|
|
||||||
stockDays: 6,
|
|
||||||
confidence: 88
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'weekly-vasos-cafe',
|
|
||||||
product: 'Vasos de Café',
|
|
||||||
emoji: '🥤',
|
|
||||||
suggestedQuantity: 500,
|
|
||||||
currentStock: 100,
|
|
||||||
unit: 'unidades',
|
|
||||||
frequency: 'biweekly' as const,
|
|
||||||
nextOrderDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
||||||
supplier: 'Suministros Hostelería',
|
|
||||||
estimatedCost: 40.00,
|
|
||||||
stockDays: 2,
|
|
||||||
confidence: 90
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ import TodayProduction from '../../components/simple/TodayProduction';
|
|||||||
import QuickActions from '../../components/simple/QuickActions';
|
import QuickActions from '../../components/simple/QuickActions';
|
||||||
import QuickOverview from '../../components/simple/QuickOverview';
|
import QuickOverview from '../../components/simple/QuickOverview';
|
||||||
import OrderSuggestions from '../../components/simple/OrderSuggestions';
|
import OrderSuggestions from '../../components/simple/OrderSuggestions';
|
||||||
|
import WeatherContext from '../../components/simple/WeatherContext';
|
||||||
|
|
||||||
interface DashboardPageProps {
|
interface DashboardPageProps {
|
||||||
onNavigateToOrders?: () => void;
|
onNavigateToOrders?: () => void;
|
||||||
@@ -133,9 +134,12 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|||||||
{weather && (
|
{weather && (
|
||||||
<div className="flex items-center text-sm text-gray-600 bg-gray-50 rounded-lg px-4 py-2">
|
<div className="flex items-center text-sm text-gray-600 bg-gray-50 rounded-lg px-4 py-2">
|
||||||
<span className="text-lg mr-2">
|
<span className="text-lg mr-2">
|
||||||
{weather.precipitation > 0 ? '🌧️' : weather.temperature > 20 ? '☀️' : '⛅'}
|
{weather.precipitation && weather.precipitation > 0 ? '🌧️' : weather.temperature && weather.temperature > 20 ? '☀️' : '⛅'}
|
||||||
</span>
|
</span>
|
||||||
<span>{weather.temperature}°C</span>
|
<div className="flex flex-col">
|
||||||
|
<span>{weather.temperature?.toFixed(1) || '--'}°C</span>
|
||||||
|
<span className="text-xs text-gray-500">AEMET</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -217,6 +221,9 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Weather & Context - Comprehensive AEMET Data */}
|
||||||
|
<WeatherContext />
|
||||||
|
|
||||||
{/* Production Section - Core Operations */}
|
{/* Production Section - Core Operations */}
|
||||||
<TodayProduction
|
<TodayProduction
|
||||||
items={mockProduction}
|
items={mockProduction}
|
||||||
@@ -234,25 +241,6 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|||||||
onNavigateToReports={onNavigateToReports}
|
onNavigateToReports={onNavigateToReports}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Weather Impact Alert - Context Aware */}
|
|
||||||
{weather && weather.precipitation > 0 && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<span className="text-2xl mr-3">🌧️</span>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-900">Impacto del Clima Detectado</h4>
|
|
||||||
<p className="text-blue-800 text-sm mt-1">
|
|
||||||
Se esperan precipitaciones ({weather.precipitation}mm). Las predicciones se han ajustado
|
|
||||||
automáticamente considerando una reducción del 15% en el tráfico.
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex items-center text-xs text-blue-700">
|
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full mr-2"></div>
|
|
||||||
Producción y pedidos ya optimizados
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success Message - When Everything is Good */}
|
{/* Success Message - When Everything is Good */}
|
||||||
{realAlerts.length === 0 && (
|
{realAlerts.length === 0 && (
|
||||||
|
|||||||
45
frontend/src/pages/pos/POSPage.tsx
Normal file
45
frontend/src/pages/pos/POSPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { POSManagementPage } from '../../components/pos';
|
||||||
|
|
||||||
|
interface POSPageProps {
|
||||||
|
view?: 'integrations' | 'sync-status' | 'transactions';
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSPage: React.FC<POSPageProps> = ({ view = 'integrations' }) => {
|
||||||
|
// For now, all views route to the main POS management page
|
||||||
|
// In the future, you can create separate components for different views
|
||||||
|
|
||||||
|
switch (view) {
|
||||||
|
case 'integrations':
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<POSManagementPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'sync-status':
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Future: Create dedicated sync status view */}
|
||||||
|
<POSManagementPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'transactions':
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Future: Create dedicated transactions view */}
|
||||||
|
<POSManagementPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<POSManagementPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default POSPage;
|
||||||
1
frontend/src/pages/pos/index.ts
Normal file
1
frontend/src/pages/pos/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as POSPage } from './POSPage';
|
||||||
@@ -20,6 +20,7 @@ import OrdersPage from '../pages/orders/OrdersPage';
|
|||||||
import InventoryPage from '../pages/inventory/InventoryPage';
|
import InventoryPage from '../pages/inventory/InventoryPage';
|
||||||
import SalesPage from '../pages/sales/SalesPage';
|
import SalesPage from '../pages/sales/SalesPage';
|
||||||
import RecipesPage from '../pages/recipes/RecipesPage';
|
import RecipesPage from '../pages/recipes/RecipesPage';
|
||||||
|
import POSPage from '../pages/pos/POSPage';
|
||||||
|
|
||||||
// Analytics Hub Pages
|
// Analytics Hub Pages
|
||||||
import AnalyticsLayout from '../components/layout/AnalyticsLayout';
|
import AnalyticsLayout from '../components/layout/AnalyticsLayout';
|
||||||
@@ -177,10 +178,27 @@ export const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: 'customer-orders',
|
path: 'customer-orders',
|
||||||
element: <SalesPage view="customer-orders" />
|
element: <SalesPage view="customer-orders" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pos',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <POSPage />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'pos-integration',
|
path: 'integrations',
|
||||||
element: <SalesPage view="pos-integration" />
|
element: <POSPage view="integrations" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sync-status',
|
||||||
|
element: <POSPage view="sync-status" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'transactions',
|
||||||
|
element: <POSPage view="transactions" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
27
services/external/app/external/aemet.py
vendored
27
services/external/app/external/aemet.py
vendored
@@ -522,20 +522,39 @@ class AEMETClient(BaseAPIClient):
|
|||||||
async def get_current_weather(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]:
|
async def get_current_weather(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]:
|
||||||
"""Get current weather for coordinates"""
|
"""Get current weather for coordinates"""
|
||||||
try:
|
try:
|
||||||
|
logger.info("🌤️ Getting current weather from AEMET",
|
||||||
|
lat=latitude, lon=longitude, api_key_configured=bool(self.api_key))
|
||||||
|
|
||||||
|
# Check if API key is configured
|
||||||
|
if not self.api_key:
|
||||||
|
logger.error("❌ AEMET API key not configured - falling back to synthetic data")
|
||||||
|
return await self._get_synthetic_current_weather()
|
||||||
|
|
||||||
station_id = self.location_service.find_nearest_station(latitude, longitude)
|
station_id = self.location_service.find_nearest_station(latitude, longitude)
|
||||||
if not station_id:
|
if not station_id:
|
||||||
logger.warning("No weather station found", lat=latitude, lon=longitude)
|
logger.warning("❌ No weather station found for coordinates",
|
||||||
|
lat=latitude, lon=longitude,
|
||||||
|
madrid_bounds=f"{AEMETConstants.MADRID_BOUNDS.min_lat}-{AEMETConstants.MADRID_BOUNDS.max_lat}")
|
||||||
return await self._get_synthetic_current_weather()
|
return await self._get_synthetic_current_weather()
|
||||||
|
|
||||||
|
logger.info("✅ Found nearest weather station", station_id=station_id)
|
||||||
|
|
||||||
weather_data = await self._fetch_current_weather_data(station_id)
|
weather_data = await self._fetch_current_weather_data(station_id)
|
||||||
if weather_data:
|
if weather_data:
|
||||||
return self.parser.parse_current_weather(weather_data)
|
logger.info("✅ Successfully fetched AEMET weather data", station_id=station_id)
|
||||||
|
parsed_data = self.parser.parse_current_weather(weather_data)
|
||||||
|
# Ensure the source is set to AEMET for successful API calls
|
||||||
|
if parsed_data and isinstance(parsed_data, dict):
|
||||||
|
parsed_data["source"] = WeatherSource.AEMET.value
|
||||||
|
return parsed_data
|
||||||
|
|
||||||
logger.info("Falling back to synthetic weather data", reason="invalid_weather_data")
|
logger.warning("❌ AEMET API returned no data - falling back to synthetic",
|
||||||
|
station_id=station_id, reason="invalid_weather_data")
|
||||||
return await self._get_synthetic_current_weather()
|
return await self._get_synthetic_current_weather()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to get current weather", error=str(e))
|
logger.error("❌ AEMET API failed - falling back to synthetic",
|
||||||
|
error=str(e), error_type=type(e).__name__)
|
||||||
return await self._get_synthetic_current_weather()
|
return await self._get_synthetic_current_weather()
|
||||||
|
|
||||||
async def get_forecast(self, latitude: float, longitude: float, days: int = 7) -> List[Dict[str, Any]]:
|
async def get_forecast(self, latitude: float, longitude: float, days: int = 7) -> List[Dict[str, Any]]:
|
||||||
|
|||||||
Reference in New Issue
Block a user