From 38d314e28d4a6906947d1568771df0a17cd95284 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sat, 20 Sep 2025 22:11:05 +0200 Subject: [PATCH] Imporve the predicciones page --- frontend/src/api/services/forecasting.ts | 31 +- frontend/src/api/services/training.ts | 2 +- frontend/src/api/types/forecasting.ts | 11 + .../domain/forecasting/DemandChart.tsx | 173 +++- .../analytics/forecasting/ForecastingPage.tsx | 888 ++++++++++++------ .../app/database/models/ModelsConfigPage.tsx | 680 ++++++++++++++ .../src/pages/app/database/models/index.ts | 1 + .../procurement/ProcurementPage.tsx | 33 +- frontend/src/router/AppRouter.tsx | 11 + frontend/src/router/routes.config.ts | 10 + gateway/app/routes/tenant.py | 6 + services/forecasting/app/api/forecasts.py | 70 +- services/forecasting/app/schemas/forecasts.py | 11 + .../app/services/forecasting_service.py | 96 +- 14 files changed, 1659 insertions(+), 364 deletions(-) create mode 100644 frontend/src/pages/app/database/models/ModelsConfigPage.tsx create mode 100644 frontend/src/pages/app/database/models/index.ts diff --git a/frontend/src/api/services/forecasting.ts b/frontend/src/api/services/forecasting.ts index 052ccde5..9e587d84 100644 --- a/frontend/src/api/services/forecasting.ts +++ b/frontend/src/api/services/forecasting.ts @@ -15,10 +15,11 @@ import { DeleteForecastResponse, GetForecastsParams, ForecastingHealthResponse, + MultiDayForecastResponse, } from '../types/forecasting'; export class ForecastingService { - private readonly baseUrl = '/forecasts'; + private readonly baseUrl = '/tenants'; /** * Generate a single product forecast @@ -29,7 +30,7 @@ export class ForecastingService { request: ForecastRequest ): Promise { return apiClient.post( - `/tenants/${tenantId}${this.baseUrl}/single`, + `${this.baseUrl}/${tenantId}/forecasts/single`, request ); } @@ -43,7 +44,7 @@ export class ForecastingService { request: BatchForecastRequest ): Promise { return apiClient.post( - `/tenants/${tenantId}${this.baseUrl}/batch`, + `${this.baseUrl}/${tenantId}/forecasts/batch`, request ); } @@ -75,8 +76,8 @@ export class ForecastingService { } const queryString = searchParams.toString(); - const url = `/tenants/${tenantId}${this.baseUrl}${queryString ? `?${queryString}` : ''}`; - + const url = `${this.baseUrl}/${tenantId}/forecasts${queryString ? `?${queryString}` : ''}`; + return apiClient.get(url); } @@ -89,7 +90,7 @@ export class ForecastingService { forecastId: string ): Promise { return apiClient.get( - `/tenants/${tenantId}${this.baseUrl}/${forecastId}` + `${this.baseUrl}/${tenantId}/forecasts/${forecastId}` ); } @@ -102,7 +103,7 @@ export class ForecastingService { forecastId: string ): Promise { return apiClient.delete( - `/tenants/${tenantId}${this.baseUrl}/${forecastId}` + `${this.baseUrl}/${tenantId}/forecasts/${forecastId}` ); } @@ -114,7 +115,21 @@ export class ForecastingService { tenantId: string ): Promise { return apiClient.get( - `/tenants/${tenantId}${this.baseUrl}/statistics` + `${this.baseUrl}/${tenantId}/forecasts/statistics` + ); + } + + /** + * Generate multi-day forecasts for a single product + * POST /tenants/{tenant_id}/forecasts/multi-day + */ + async createMultiDayForecast( + tenantId: string, + request: ForecastRequest + ): Promise { + return apiClient.post( + `${this.baseUrl}/${tenantId}/forecasts/multi-day`, + request ); } diff --git a/frontend/src/api/services/training.ts b/frontend/src/api/services/training.ts index b4bcddec..cc6f450c 100644 --- a/frontend/src/api/services/training.ts +++ b/frontend/src/api/services/training.ts @@ -83,7 +83,7 @@ class TrainingService { const queryString = params.toString() ? `?${params.toString()}` : ''; return apiClient.get>( - `${this.baseUrl}/${tenantId}/models${queryString}` + `${this.baseUrl}/${tenantId}/models/${queryString}` ); } diff --git a/frontend/src/api/types/forecasting.ts b/frontend/src/api/types/forecasting.ts index 5e2ed60d..f16fb3ba 100644 --- a/frontend/src/api/types/forecasting.ts +++ b/frontend/src/api/types/forecasting.ts @@ -157,4 +157,15 @@ export interface ForecastingHealthResponse { version: string; features: string[]; timestamp: string; +} + +export interface MultiDayForecastResponse { + tenant_id: string; + inventory_product_id: string; + forecast_start_date: string; // ISO date string + forecast_days: number; + forecasts: ForecastResponse[]; + total_predicted_demand: number; + average_confidence_level: number; + processing_time_ms: number; } \ No newline at end of file diff --git a/frontend/src/components/domain/forecasting/DemandChart.tsx b/frontend/src/components/domain/forecasting/DemandChart.tsx index 77c2c495..b4000ceb 100644 --- a/frontend/src/components/domain/forecasting/DemandChart.tsx +++ b/frontend/src/components/domain/forecasting/DemandChart.tsx @@ -88,9 +88,15 @@ const DemandChart: React.FC = ({ // Process forecast data for chart const chartData = useMemo(() => { + console.log('🔍 Processing forecast data for chart:', data); + const processedData: ChartDataPoint[] = data.map(forecast => { + // Convert forecast_date to a proper date format for the chart + const forecastDate = new Date(forecast.forecast_date); + const dateString = forecastDate.toISOString().split('T')[0]; + return { - date: forecast.forecast_date, + date: dateString, actualDemand: undefined, // Not available in current forecast response predictedDemand: forecast.predicted_demand, confidenceLower: forecast.confidence_lower, @@ -99,23 +105,32 @@ const DemandChart: React.FC = ({ }; }); + console.log('📊 Processed chart data:', processedData); return processedData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); }, [data]); // Filter data based on selected period const filteredData = useMemo(() => { + console.log('🔍 Filtering data - selected period:', selectedPeriod); + console.log('🔍 Chart data before filtering:', chartData); + if (!selectedPeriod.start || !selectedPeriod.end) { + console.log('📊 No period filter, returning all chart data'); return chartData; } - - return chartData.filter(point => { + + const filtered = chartData.filter(point => { const pointDate = new Date(point.date); return pointDate >= selectedPeriod.start! && pointDate <= selectedPeriod.end!; }); + + console.log('📊 Filtered data:', filtered); + return filtered; }, [chartData, selectedPeriod]); // Update zoomed data when filtered data changes useEffect(() => { + console.log('🔍 Setting zoomed data from filtered data:', filteredData); setZoomedData(filteredData); }, [filteredData]); @@ -221,8 +236,11 @@ const DemandChart: React.FC = ({ ); } - // Empty state - if (zoomedData.length === 0) { + // Use filteredData if zoomedData is empty but we have data + const displayData = zoomedData.length > 0 ? zoomedData : filteredData; + + // Empty state - only show if we truly have no data + if (displayData.length === 0 && chartData.length === 0) { return ( @@ -286,7 +304,7 @@ const DemandChart: React.FC = ({ )} {/* Reset zoom */} - {zoomedData.length !== filteredData.length && ( + {displayData.length !== filteredData.length && ( @@ -298,28 +316,57 @@ const DemandChart: React.FC = ({
- - - + + + + + + + + + + + + { const date = new Date(value); - return timeframe === 'weekly' - ? date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }) - : timeframe === 'monthly' - ? date.toLocaleDateString('es-ES', { month: 'short', year: '2-digit' }) - : date.getFullYear().toString(); + return date.toLocaleDateString('es-ES', { + month: 'short', + day: 'numeric', + weekday: displayData.length <= 7 ? 'short' : undefined + }); }} /> - `${value}`} + value.toFixed(0)} + domain={['dataMin - 5', 'dataMax + 5']} + /> + } + cursor={{ stroke: '#10b981', strokeWidth: 1, strokeOpacity: 0.5 }} + /> + - } /> - {/* Confidence interval area */} {showConfidenceInterval && ( @@ -328,9 +375,9 @@ const DemandChart: React.FC = ({ dataKey="confidenceUpper" stackId={1} stroke="none" - fill="#10b98120" - fillOpacity={0.3} - name="Intervalo de Confianza Superior" + fill="url(#confidenceGradient)" + fillOpacity={0.4} + name="Límite Superior" /> )} {showConfidenceInterval && ( @@ -340,20 +387,40 @@ const DemandChart: React.FC = ({ stackId={1} stroke="none" fill="#ffffff" - name="Intervalo de Confianza Inferior" + name="Límite Inferior" /> )} + {/* Background area for main prediction */} + + {/* Predicted demand line */} @@ -386,18 +453,46 @@ const DemandChart: React.FC = ({
)} - {/* Chart legend */} -
-
-
- Demanda Predicha -
- {showConfidenceInterval && ( + {/* Enhanced Chart legend and insights */} +
+
-
- Intervalo de Confianza +
+ Demanda Predicha
- )} + {showConfidenceInterval && ( +
+
+ Intervalo de Confianza +
+ )} +
+
+ Puntos de Datos +
+
+ + {/* Quick stats */} +
+
+
+ {Math.min(...displayData.map(d => d.predictedDemand || 0)).toFixed(1)} +
+
Mínimo
+
+
+
+ {(displayData.reduce((sum, d) => sum + (d.predictedDemand || 0), 0) / displayData.length).toFixed(1)} +
+
Promedio
+
+
+
+ {Math.max(...displayData.map(d => d.predictedDemand || 0)).toFixed(1)} +
+
Máximo
+
+
diff --git a/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx b/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx index 7c3e5535..ce63c160 100644 --- a/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx +++ b/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx @@ -1,29 +1,34 @@ import React, { useState, useMemo } from 'react'; -import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader } from 'lucide-react'; -import { Button, Card, Badge, Select, Table } from '../../../../components/ui'; +import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer } from 'lucide-react'; +import { Button, Card, Badge, Select, Table, StatsGrid } from '../../../../components/ui'; import type { TableColumn } from '../../../../components/ui'; import { PageHeader } from '../../../../components/layout'; -import { DemandChart, ForecastTable, SeasonalityIndicator, AlertsPanel } from '../../../../components/domain/forecasting'; -import { useTenantForecasts, useForecastStatistics } from '../../../../api/hooks/forecasting'; +import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting'; +import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting'; import { useIngredients } from '../../../../api/hooks/inventory'; -import { useAuthUser } from '../../../../stores/auth.store'; +import { useModels } from '../../../../api/hooks/training'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; import { ForecastResponse } from '../../../../api/types/forecasting'; +import { forecastingService } from '../../../../api/services/forecasting'; const ForecastingPage: React.FC = () => { - const [selectedProduct, setSelectedProduct] = useState('all'); + const [selectedProduct, setSelectedProduct] = useState(''); const [forecastPeriod, setForecastPeriod] = useState('7'); const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart'); + const [isGenerating, setIsGenerating] = useState(false); + const [hasGeneratedForecast, setHasGeneratedForecast] = useState(false); + const [currentForecastData, setCurrentForecastData] = useState([]); - // Get tenant ID from auth user - const user = useAuthUser(); - const tenantId = user?.tenant_id || ''; + // Get tenant ID from tenant store + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; // Calculate date range based on selected period const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - parseInt(forecastPeriod)); - // API hooks + // Fetch existing forecasts const { data: forecastsData, isLoading: forecastsLoading, @@ -31,15 +36,12 @@ const ForecastingPage: React.FC = () => { } = useTenantForecasts(tenantId, { start_date: startDate.toISOString().split('T')[0], end_date: endDate.toISOString().split('T')[0], - ...(selectedProduct !== 'all' && { inventory_product_id: selectedProduct }), + ...(selectedProduct && { inventory_product_id: selectedProduct }), limit: 100 + }, { + enabled: !!tenantId && hasGeneratedForecast && !!selectedProduct }); - const { - data: statisticsData, - isLoading: statisticsLoading, - error: statisticsError - } = useForecastStatistics(tenantId); // Fetch real inventory data const { @@ -48,21 +50,48 @@ const ForecastingPage: React.FC = () => { error: ingredientsError } = useIngredients(tenantId); - // Build products list from real inventory data + // Fetch trained models to filter products + const { + data: modelsData, + isLoading: modelsLoading, + error: modelsError + } = useModels(tenantId, { active_only: true }); + + // Forecast generation mutation + const createForecastMutation = useCreateSingleForecast({ + onSuccess: (data) => { + setIsGenerating(false); + setHasGeneratedForecast(true); + // Store the generated forecast data locally for immediate display + setCurrentForecastData([data]); + }, + onError: (error) => { + setIsGenerating(false); + console.error('Error generating forecast:', error); + }, + }); + + // Build products list from ingredients that have trained models const products = useMemo(() => { - const productList = [{ id: 'all', name: 'Todos los productos' }]; - - if (ingredientsData && ingredientsData.length > 0) { - const inventoryProducts = ingredientsData.map(ingredient => ({ - id: ingredient.id, - name: ingredient.name, - category: ingredient.category, - })); - productList.push(...inventoryProducts); + if (!ingredientsData || !modelsData?.models) { + return []; } - - return productList; - }, [ingredientsData]); + + // Get inventory product IDs that have trained models + const modelProductIds = new Set(modelsData.models.map(model => model.inventory_product_id)); + + // Filter ingredients to only those with models + const ingredientsWithModels = ingredientsData.filter(ingredient => + modelProductIds.has(ingredient.id) + ); + + return ingredientsWithModels.map(ingredient => ({ + id: ingredient.id, + name: ingredient.name, + category: ingredient.category, + hasModel: true + })); + }, [ingredientsData, modelsData]); const periods = [ { value: '7', label: '7 días' }, @@ -71,99 +100,107 @@ const ForecastingPage: React.FC = () => { { value: '90', label: '3 meses' }, ]; - // Transform forecast data for table display - const transformForecastsForTable = (forecasts: ForecastResponse[]) => { - return forecasts.map(forecast => ({ - id: forecast.id, - product: forecast.inventory_product_id, // Will need to map to product name - currentStock: 'N/A', // Not available in forecast data - forecastDemand: forecast.predicted_demand, - recommendedProduction: Math.ceil(forecast.predicted_demand * 1.1), // Simple calculation - confidence: Math.round(forecast.confidence_level * 100), - trend: forecast.predicted_demand > 0 ? 'up' : 'stable', - stockoutRisk: forecast.confidence_level > 0.8 ? 'low' : forecast.confidence_level > 0.6 ? 'medium' : 'high', - })); - }; + // Handle forecast generation + const handleGenerateForecast = async () => { + if (!tenantId || !selectedProduct) { + alert('Por favor, selecciona un ingrediente para generar predicciones.'); + return; + } - // Generate alerts based on forecast data - const generateAlertsFromForecasts = (forecasts: ForecastResponse[]) => { - return forecasts - .filter(forecast => forecast.confidence_level < 0.7 || forecast.predicted_demand > 50) - .slice(0, 3) // Limit to 3 alerts - .map((forecast, index) => ({ - id: (index + 1).toString(), - type: forecast.confidence_level < 0.7 ? 'low-confidence' : 'high-demand', - product: forecast.inventory_product_id, - message: forecast.confidence_level < 0.7 - ? `Baja confianza en predicción (${Math.round(forecast.confidence_level * 100)}%)` - : `Alta demanda prevista: ${forecast.predicted_demand} unidades`, - severity: forecast.confidence_level < 0.5 ? 'high' : 'medium', - recommendation: forecast.confidence_level < 0.7 - ? 'Revisar datos históricos y factores externos' - : `Considerar aumentar producción a ${Math.ceil(forecast.predicted_demand * 1.2)} unidades` - })); - }; + setIsGenerating(true); - // Extract weather data from first forecast (if available) - const getWeatherImpact = (forecasts: ForecastResponse[]) => { - const firstForecast = forecasts?.[0]; - if (!firstForecast) return null; - - return { - today: firstForecast.weather_description || 'N/A', - temperature: firstForecast.weather_temperature || 0, - demandFactor: 1.0, // Could be calculated based on weather - affectedCategories: [], // Could be derived from business logic - }; - }; + try { + const today = new Date(); + const forecastRequest = { + inventory_product_id: selectedProduct, + forecast_date: today.toISOString().split('T')[0], + forecast_days: parseInt(forecastPeriod), + location: 'default', + confidence_level: 0.8, + }; - const getTrendIcon = (trend: string) => { - switch (trend) { - case 'up': - return ; - case 'down': - return ; - default: - return
; + // Use the new multi-day endpoint for all forecasts + const multiDayResult = await forecastingService.createMultiDayForecast(tenantId, forecastRequest); + setIsGenerating(false); + setHasGeneratedForecast(true); + // Use the forecasts from the multi-day response + setCurrentForecastData(multiDayResult.forecasts); + } catch (error) { + console.error('Failed to generate forecast:', error); + setIsGenerating(false); } }; - const getRiskBadge = (risk: string) => { - const riskConfig = { - low: { color: 'green', text: 'Bajo' }, - medium: { color: 'yellow', text: 'Medio' }, - high: { color: 'red', text: 'Alto' }, - }; - - const config = riskConfig[risk as keyof typeof riskConfig]; - return {config?.text}; + // Transform forecast data for table display - only real data + const transformForecastsForTable = (forecasts: ForecastResponse[]) => { + return forecasts.map(forecast => ({ + id: forecast.id, + product: forecast.inventory_product_id, + forecastDate: forecast.forecast_date, + forecastDemand: forecast.predicted_demand, + confidence: Math.round(forecast.confidence_level * 100), + confidenceRange: `${forecast.confidence_lower?.toFixed(1) || 'N/A'} - ${forecast.confidence_upper?.toFixed(1) || 'N/A'}`, + algorithm: forecast.algorithm, + })); }; + + // Extract weather data from all forecasts for 7-day view + const getWeatherImpact = (forecasts: ForecastResponse[]) => { + if (!forecasts || forecasts.length === 0) return null; + + // Calculate average temperature across all forecast days + const avgTemp = forecasts.reduce((sum, f) => sum + (f.weather_temperature || 0), 0) / forecasts.length; + const tempRange = { + min: Math.min(...forecasts.map(f => f.weather_temperature || 0)), + max: Math.max(...forecasts.map(f => f.weather_temperature || 0)) + }; + + // Aggregate weather descriptions + const weatherTypes = forecasts + .map(f => f.weather_description) + .filter(Boolean) + .reduce((acc, desc) => { + acc[desc] = (acc[desc] || 0) + 1; + return acc; + }, {} as Record); + + const dominantWeather = Object.entries(weatherTypes) + .sort(([,a], [,b]) => b - a)[0]?.[0] || 'N/A'; + + return { + avgTemperature: Math.round(avgTemp), + tempRange, + dominantWeather, + forecastDays: forecasts.length, + dailyForecasts: forecasts.map(f => ({ + date: f.forecast_date, + temperature: f.weather_temperature, + description: f.weather_description, + predicted_demand: f.predicted_demand + })) + }; + }; + + const forecastColumns: TableColumn[] = [ { key: 'product', - title: 'Producto', + title: 'Producto ID', dataIndex: 'product', }, { - key: 'currentStock', - title: 'Stock Actual', - dataIndex: 'currentStock', + key: 'forecastDate', + title: 'Fecha', + dataIndex: 'forecastDate', + render: (value) => new Date(value).toLocaleDateString('es-ES'), }, { key: 'forecastDemand', title: 'Demanda Prevista', dataIndex: 'forecastDemand', render: (value) => ( - {value} - ), - }, - { - key: 'recommendedProduction', - title: 'Producción Recomendada', - dataIndex: 'recommendedProduction', - render: (value) => ( - {value} + {value?.toFixed(2) || 'N/A'} ), }, { @@ -173,37 +210,91 @@ const ForecastingPage: React.FC = () => { render: (value) => `${value}%`, }, { - key: 'trend', - title: 'Tendencia', - dataIndex: 'trend', - render: (value) => ( -
- {getTrendIcon(value)} -
- ), + key: 'confidenceRange', + title: 'Rango de Confianza', + dataIndex: 'confidenceRange', }, { - key: 'stockoutRisk', - title: 'Riesgo Agotamiento', - dataIndex: 'stockoutRisk', - render: (value) => getRiskBadge(value), + key: 'algorithm', + title: 'Algoritmo', + dataIndex: 'algorithm', }, ]; - // Derived data from API responses - const forecasts = forecastsData?.forecasts || []; + // Use either current forecast data or fetched data + const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []); const transformedForecasts = transformForecastsForTable(forecasts); - const alerts = generateAlertsFromForecasts(forecasts); const weatherImpact = getWeatherImpact(forecasts); - const isLoading = forecastsLoading || statisticsLoading || ingredientsLoading; - const hasError = forecastsError || statisticsError || ingredientsError; + const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating; + const hasError = forecastsError || ingredientsError || modelsError; // Calculate metrics from real data const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0); - const averageConfidence = forecasts.length > 0 + const averageConfidence = forecasts.length > 0 ? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100) : 0; + // Get forecast insights from the latest forecast - only real backend data + const getForecastInsights = (forecast: ForecastResponse) => { + const insights = []; + + // Weather data (only factual) + if (forecast.weather_temperature) { + insights.push({ + type: 'weather', + icon: Thermometer, + title: 'Temperatura', + description: `${forecast.weather_temperature}°C`, + impact: 'info' + }); + } + + if (forecast.weather_description) { + insights.push({ + type: 'weather', + icon: CloudRain, + title: 'Condición Climática', + description: forecast.weather_description, + impact: 'info' + }); + } + + // Temporal factors (only factual) + if (forecast.is_weekend) { + insights.push({ + type: 'temporal', + icon: Calendar, + title: 'Fin de Semana', + description: 'Día de fin de semana', + impact: 'info' + }); + } + + if (forecast.is_holiday) { + insights.push({ + type: 'temporal', + icon: Calendar, + title: 'Día Festivo', + description: 'Día festivo', + impact: 'info' + }); + } + + // Model confidence (factual) + insights.push({ + type: 'model', + icon: Target, + title: 'Confianza del Modelo', + description: `${Math.round(forecast.confidence_level * 100)}%`, + impact: forecast.confidence_level > 0.8 ? 'positive' : forecast.confidence_level > 0.6 ? 'moderate' : 'high' + }); + + return insights; + }; + + const currentInsights = forecasts.length > 0 ? getForecastInsights(forecasts[0]) : []; + + return (
{ {isLoading && ( - Cargando predicciones... + + {isGenerating ? 'Generando nuevas predicciones...' : 'Cargando predicciones...'} + )} {hasError && ( - +
- - Error al cargar las predicciones. Por favor, inténtalo de nuevo. + + Error al cargar las predicciones. Por favor, inténtalo de nuevo.
)} {!isLoading && !hasError && ( <> - {/* Key Metrics */} -
- -
-
-

Precisión del Modelo

-

- {statisticsData?.accuracy_metrics?.average_accuracy - ? Math.round(statisticsData.accuracy_metrics.average_accuracy * 100) - : averageConfidence}% -

-
-
- -
-
-
- -
-
-

Demanda Prevista

-

{Math.round(totalDemand)}

-

próximos {forecastPeriod} días

-
- -
-
- - -
-
-

Tendencia

-

- +{statisticsData?.accuracy_metrics?.accuracy_trend - ? Math.round(statisticsData.accuracy_metrics.accuracy_trend * 100) - : 5}% -

-

vs período anterior

-
- -
-
- - -
-
-

Total Predicciones

-

- {statisticsData?.total_forecasts || forecasts.length} -

-

generadas

-
-
- - - -
-
-
-
)} - {/* Controls */} + {/* Forecast Configuration */} -
-
-
- - -
+

Configurar Predicción

+
+ {/* Step 1: Select Ingredient */} +
+ + + {products.length === 0 && ( +

+ No hay ingredientes con modelos entrenados +

+ )} +
-
- - -
+ {/* Step 2: Select Period */} +
+ + +
-
- -
- - + {/* Step 3: Generate */} +
+ + +
+
+ + {selectedProduct && ( +
+

+ Ingrediente seleccionado: {products.find(p => p.id === selectedProduct)?.name} +

+

+ Se generará una predicción de demanda para los próximos {forecastPeriod} días usando IA +

+
+ )} + + + {/* Results Section - Only show after generating forecast */} + {hasGeneratedForecast && forecasts.length > 0 && ( + <> + {/* Enhanced Layout Structure */} +
+ + {/* Key Metrics Row - Using StatsGrid */} + f.predicted_demand)) - Math.min(...forecasts.map(f => f.predicted_demand))).toFixed(1), + icon: BarChart3, + variant: "warning", + size: "sm" + } + ]} + /> + + {/* Main Content Grid */} +
+ + {/* Chart Section - Takes most space */} +
+ +
+
+
+

Predicción de Demanda

+

+ {products.find(p => p.id === selectedProduct)?.name} • {forecastPeriod} días • {forecasts.length} puntos +

+
+
+
+ + +
+ +
+
+
+ +
+ {viewMode === 'chart' ? ( + + ) : ( + + )} +
+
+
+ + {/* Right Sidebar - Insights */} +
+ {/* Weather & External Factors */} +
+ {/* Forecast Insights */} + {currentInsights.length > 0 && ( + +

Factores que Afectan la Predicción

+
+ {currentInsights.map((insight, index) => { + const IconComponent = insight.icon; + return ( +
+
+ +
+
+

{insight.title}

+

{insight.description}

+
+
+ ); + })} +
+
+ )} + + {/* Weather Impact */} + {weatherImpact && ( + +
+

+ + Clima ({weatherImpact.forecastDays} días) +

+

Impacto meteorológico en la demanda

+
+
+ {/* Temperature Overview */} +
+
+
+ + {weatherImpact.avgTemperature}°C +
+

Promedio

+
+
+
+

{weatherImpact.dominantWeather}

+

Condición

+
+
+
+ + {/* Daily forecast - compact */} +
+

Pronóstico detallado:

+
+ {weatherImpact.dailyForecasts.slice(0, 5).map((day, index) => ( +
+ + {new Date(day.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })} + +
+ {day.temperature}°C + {day.predicted_demand?.toFixed(0)} +
+
+ ))} +
+
+
+
+ )} + + + {/* Model Information */} + {forecasts.length > 0 && ( + +
+

+ + Modelo IA +

+

Información técnica del algoritmo

+
+
+
+
+ Algoritmo + {forecasts[0]?.algorithm || 'N/A'} +
+
+ Versión + {forecasts[0]?.model_version || 'N/A'} +
+
+ Tiempo + {forecasts[0]?.processing_time_ms || 'N/A'}ms +
+
+
+
+ )}
-
- - -
- {/* Main Forecast Display */} -
- {viewMode === 'chart' ? ( - - ) : ( - - )} -
- - {/* Alerts Panel */} -
- - - {/* Weather Impact */} - {weatherImpact && ( - -

Impacto Meteorológico

-
-
- Hoy: -
- {weatherImpact.temperature}°C -
-
-
- -
- Condiciones: - {weatherImpact.today} -
- -
- Factor de demanda: - {weatherImpact.demandFactor}x -
- - {weatherImpact.affectedCategories.length > 0 && ( -
-

Categorías afectadas:

-
- {weatherImpact.affectedCategories.map((category, index) => ( - {category} - ))} -
-
- )} -
-
- )} - - {/* Model Performance */} - {statisticsData?.model_performance && ( - -

Rendimiento del Modelo

-
-
-
- Algoritmo principal - {statisticsData.model_performance.most_used_algorithm} -
-
- Tiempo de procesamiento promedio - - {Math.round(statisticsData.model_performance.average_processing_time)}ms - -
-
-
-
- )} -
-
+
+ + )} {/* Detailed Forecasts Table */} {!isLoading && !hasError && transformedForecasts.length > 0 && ( @@ -448,15 +672,57 @@ const ForecastingPage: React.FC = () => { )} - {!isLoading && !hasError && transformedForecasts.length === 0 && ( + {/* Empty States */} + {!isLoading && !hasError && products.length === 0 && (
- -

No hay predicciones disponibles

-

- No se encontraron predicciones para el período seleccionado. - Prueba ajustando los filtros o genera nuevas predicciones. + +

No hay ingredientes con modelos entrenados

+

+ Para generar predicciones, primero necesitas entrenar modelos de IA para tus ingredientes. + Ve a la página de Modelos IA para entrenar modelos para tus ingredientes.

+
+ +
+
+
+ )} + + {!isLoading && !hasError && products.length > 0 && !hasGeneratedForecast && ( + +
+ +

Listo para Generar Predicciones

+

+ Tienes {products.length} ingrediente{products.length > 1 ? 's' : ''} con modelos entrenados disponibles. + Selecciona un ingrediente y período para comenzar. +

+
+
+
1
+

Selecciona Ingrediente

+

Elige un ingrediente con modelo IA

+
+
+
2
+

Define Período

+

Establece días a predecir

+
+
+
3
+

Generar Predicción

+

Obtén insights de IA

+
+
)} diff --git a/frontend/src/pages/app/database/models/ModelsConfigPage.tsx b/frontend/src/pages/app/database/models/ModelsConfigPage.tsx new file mode 100644 index 00000000..ac551f3d --- /dev/null +++ b/frontend/src/pages/app/database/models/ModelsConfigPage.tsx @@ -0,0 +1,680 @@ +import React, { useState, useMemo } from 'react'; +import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react'; +import { Button, Card, Badge, Modal, Table, Select, Input } from '../../../../components/ui'; +import { PageHeader } from '../../../../components/layout'; +import { useToast } from '../../../../hooks/ui/useToast'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { useIngredients } from '../../../../api/hooks/inventory'; +import { + useModels, + useActiveModel, + useTrainSingleProduct, + useModelMetrics, + useModelPerformance, + useTenantTrainingStatistics +} from '../../../../api/hooks/training'; +import type { IngredientResponse } from '../../../../api/types/inventory'; +import type { TrainedModelResponse, SingleProductTrainingRequest } from '../../../../api/types/training'; + +// Actual API response structure (different from expected paginated response) +interface ModelsApiResponse { + tenant_id: string; + models: TrainedModelResponse[]; + total_returned: number; + active_only: boolean; + pagination: any; + enhanced_features: boolean; + repository_integration: boolean; +} + +interface ModelStatus { + ingredient: IngredientResponse; + hasModel: boolean; + model?: TrainedModelResponse; + isTraining: boolean; + trainingJobId?: string; + lastTrainingDate?: string; + accuracy?: number; + status: 'no_model' | 'active' | 'training' | 'error'; +} + +const ModelsConfigPage: React.FC = () => { + const { addToast } = useToast(); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const [selectedIngredient, setSelectedIngredient] = useState(null); + const [showTrainingModal, setShowTrainingModal] = useState(false); + const [showModelDetailsModal, setShowModelDetailsModal] = useState(false); + const [trainingSettings, setTrainingSettings] = useState>({ + seasonality_mode: 'additive', + daily_seasonality: true, + weekly_seasonality: true, + yearly_seasonality: false, + }); + + // API hooks + const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId); + const { data: modelsData, isLoading: modelsLoading, error: modelsError } = useModels(tenantId); + const { data: statistics, error: statsError } = useTenantTrainingStatistics(tenantId); + const trainMutation = useTrainSingleProduct(); + + // Debug: Log the models data structure + React.useEffect(() => { + console.log('Models data structure:', { + modelsData, + modelsLoading, + modelsError, + ingredients: ingredients.length + }); + }, [modelsData, modelsLoading, modelsError, ingredients]); + + // Build model status for each ingredient + const modelStatuses = useMemo(() => { + // Handle different possible data structures from the API response + let models: TrainedModelResponse[] = []; + + // The API actually returns { models: [...], tenant_id: ..., total_returned: ... } + const apiResponse = modelsData as any as ModelsApiResponse; + if (apiResponse?.models && Array.isArray(apiResponse.models)) { + models = apiResponse.models; + } else if (modelsData?.data && Array.isArray(modelsData.data)) { + models = modelsData.data; + } else if (modelsData && Array.isArray(modelsData)) { + models = modelsData as any; + } + + console.log('Processing models:', { + modelsData, + extractedModels: models, + modelsCount: models.length, + ingredientsCount: ingredients.length + }); + + return ingredients.map((ingredient: IngredientResponse) => { + const model = models.find((m: any) => { + // Focus only on ID-based matching + return m.inventory_product_id === ingredient.id || + String(m.inventory_product_id) === String(ingredient.id); + }); + + const isTraining = false; // We'll track this separately for active training jobs + + console.log(`Ingredient ${ingredient.name} (${ingredient.id}):`, { + hasModel: !!model, + model: model ? { + id: model.model_id, + created: model.created_at, + inventory_product_id: model.inventory_product_id + } : null + }); + + return { + ingredient, + hasModel: !!model, + model, + isTraining, + lastTrainingDate: model?.created_at, + accuracy: model?.training_metrics?.mape ? (100 - model.training_metrics.mape) : undefined, + status: model + ? (isTraining ? 'training' : 'active') + : 'no_model' + }; + }); + }, [ingredients, modelsData]); + + // Calculate orphaned models (models for ingredients that no longer exist) + const orphanedModels = useMemo(() => { + const apiResponse = modelsData as any as ModelsApiResponse; + const models = apiResponse?.models || []; + const ingredientIds = new Set(ingredients.map(ing => ing.id)); + + return models.filter((model: any) => !ingredientIds.has(model.inventory_product_id)); + }, [modelsData, ingredients]); + + // Filter and search + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + + const filteredStatuses = useMemo(() => { + return modelStatuses.filter(status => { + const matchesSearch = status.ingredient.name.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesStatus = statusFilter === 'all' || status.status === statusFilter; + return matchesSearch && matchesStatus; + }); + }, [modelStatuses, searchTerm, statusFilter]); + + const handleTrainModel = async () => { + if (!selectedIngredient) return; + + try { + await trainMutation.mutateAsync({ + tenantId, + inventoryProductId: selectedIngredient.id, + request: trainingSettings + }); + + addToast(`Entrenamiento iniciado para ${selectedIngredient.name}`, { type: 'success' }); + setShowTrainingModal(false); + } catch (error) { + addToast('Error al iniciar el entrenamiento', { type: 'error' }); + } + }; + + const handleViewModelDetails = (ingredient: IngredientResponse) => { + setSelectedIngredient(ingredient); + setShowModelDetailsModal(true); + }; + + const handleStartTraining = (ingredient: IngredientResponse) => { + setSelectedIngredient(ingredient); + setShowTrainingModal(true); + }; + + const getStatusBadge = (status: ModelStatus['status']) => { + switch (status) { + case 'no_model': + return Sin modelo; + case 'active': + return Activo; + case 'training': + return Entrenando; + case 'error': + return Error; + default: + return Desconocido; + } + }; + + const getAccuracyBadge = (accuracy?: number) => { + if (!accuracy) return null; + + const variant = accuracy >= 90 ? 'success' : accuracy >= 75 ? 'warning' : 'error'; + return {accuracy.toFixed(1)}%; + }; + + // Table columns configuration + const tableColumns = [ + { + key: 'ingredient', + title: 'Ingrediente', + render: (_: any, status: ModelStatus) => ( +
+
+ {status.ingredient.name.charAt(0).toUpperCase()} +
+
+
{status.ingredient.name}
+
{status.ingredient.category}
+
+
+ ), + }, + { + key: 'status', + title: 'Estado del Modelo', + render: (_: any, status: ModelStatus) => ( +
+ {getStatusBadge(status.status)} + {status.accuracy && getAccuracyBadge(status.accuracy)} +
+ ), + }, + { + key: 'lastTrained', + title: 'Último Entrenamiento', + render: (_: any, status: ModelStatus) => ( +
+ {status.lastTrainingDate + ? new Date(status.lastTrainingDate).toLocaleDateString('es-ES') + : 'Nunca' + } +
+ ), + }, + { + key: 'actions', + title: 'Acciones', + render: (_: any, status: ModelStatus) => ( +
+ {status.hasModel && ( + + )} + +
+ ), + }, + ]; + + if (ingredientsLoading || modelsLoading) { + return ( +
+ +
+ + + {ingredientsLoading ? 'Cargando ingredientes...' : 'Cargando modelos...'} + +
+
+ ); + } + + if (modelsError) { + console.error('Error loading models:', modelsError); + } + + return ( +
+ + + + {/* Statistics Cards */} +
+ +
+
+
+ {modelStatuses.filter(s => s.hasModel).length} +
+
Ingredientes con Modelo
+
+ +
+
+ + +
+
+
+ {modelStatuses.filter(s => s.status === 'no_model').length} +
+
Sin Modelo
+
+ +
+
+ + +
+
+
+ {orphanedModels.length} +
+
Modelos Huérfanos
+
+ +
+
+ + +
+
+
+ {statsError ? 'N/A' : (statistics?.average_accuracy ? `${(100 - statistics.average_accuracy).toFixed(1)}%` : 'N/A')} +
+
Precisión Promedio
+
+ +
+
+
+ + {/* Orphaned Models Warning */} + {orphanedModels.length > 0 && ( + +
+ +
+

+ Modelos Huérfanos Detectados +

+

+ Se encontraron {orphanedModels.length} modelos entrenados para ingredientes que ya no existen en el inventario. + Estos modelos pueden ser eliminados para optimizar el espacio de almacenamiento. +

+
+
+
+ )} + + {/* Filters */} + +
+
+ setSearchTerm(e.target.value)} + /> +
+
+ setTrainingSettings(prev => ({ ...prev, seasonality_mode: value as any }))} + options={[ + { value: 'additive', label: 'Aditivo' }, + { value: 'multiplicative', label: 'Multiplicativo' } + ]} + /> +
+ +
+

Patrones Estacionales

+ + + + + + +
+
+ +
+ + +
+
+ + + {/* Model Details Modal */} + setShowModelDetailsModal(false)} + title={`Detalles del Modelo - ${selectedIngredient?.name}`} + size="lg" + > +
+ {selectedIngredient && ( + + )} +
+
+
+ ); +}; + +// Component for model details content +const ModelDetailsContent: React.FC<{ + tenantId: string; + ingredientId: string; +}> = ({ tenantId, ingredientId }) => { + const { data: activeModel } = useActiveModel(tenantId, ingredientId); + + if (!activeModel) { + return ( +
+ +

No hay modelo disponible

+

+ Este ingrediente no tiene un modelo entrenado disponible. Puedes entrenar uno nuevo usando el botón "Entrenar". +

+
+ ); + } + + const precision = activeModel.training_metrics?.mape + ? (100 - activeModel.training_metrics.mape).toFixed(1) + : 'N/A'; + + return ( +
+ {/* Model Overview */} +
+
+
+
+ {precision}% +
+
Precisión
+
+
+ +
+
+
+ {activeModel.training_metrics?.mae?.toFixed(2) || 'N/A'} +
+
MAE
+
+
+ +
+
+
+ {activeModel.training_metrics?.rmse?.toFixed(2) || 'N/A'} +
+
RMSE
+
+
+
+ + {/* Model Information */} + +

+ + Información del Modelo +

+
+
+
+ + Creado + + + {new Date(activeModel.created_at).toLocaleString('es-ES', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + +
+ +
+ + Características usadas + + + {activeModel.features_used?.length || 0} variables + +
+
+ +
+
+ + Período de entrenamiento + + + {activeModel.training_period?.start_date && activeModel.training_period?.end_date + ? `${new Date(activeModel.training_period.start_date).toLocaleDateString('es-ES')} - ${new Date(activeModel.training_period.end_date).toLocaleDateString('es-ES')}` + : 'No disponible' + } + +
+ +
+ + Hiperparámetros + + + {Object.keys(activeModel.hyperparameters || {}).length} configurados + +
+
+
+
+ + {/* Features Used */} + {activeModel.features_used && activeModel.features_used.length > 0 && ( + +

+ + Características del Modelo +

+
+ {activeModel.features_used.map((feature: string, index: number) => ( +
+ + {feature.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + +
+ ))} +
+
+ )} + + {/* Training Performance */} + {activeModel.training_metrics && ( + +

+ + Métricas de Rendimiento +

+
+
+
+ {precision}% +
+
Precisión
+
+
+
+ {activeModel.training_metrics.mae?.toFixed(2) || 'N/A'} +
+
MAE
+
+
+
+ {activeModel.training_metrics.rmse?.toFixed(2) || 'N/A'} +
+
RMSE
+
+
+
+ {activeModel.training_metrics.r2_score?.toFixed(3) || 'N/A'} +
+
+
+
+
+ )} +
+ ); +}; + +export default ModelsConfigPage; \ No newline at end of file diff --git a/frontend/src/pages/app/database/models/index.ts b/frontend/src/pages/app/database/models/index.ts new file mode 100644 index 00000000..2ac9c05c --- /dev/null +++ b/frontend/src/pages/app/database/models/index.ts @@ -0,0 +1 @@ +export { default as ModelsConfigPage } from './ModelsConfigPage'; \ No newline at end of file diff --git a/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx b/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx index b5cb3f07..2dc387fd 100644 --- a/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx +++ b/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx @@ -8,6 +8,7 @@ import { useProcurementPlans, useCurrentProcurementPlan, useCriticalRequirements, + usePlanRequirements, useGenerateProcurementPlan, useUpdateProcurementPlanStatus, useTriggerDailyScheduler @@ -22,7 +23,9 @@ const ProcurementPage: React.FC = () => { const [selectedPlan, setSelectedPlan] = useState(null); const [editingPlan, setEditingPlan] = useState(null); const [editFormData, setEditFormData] = useState({}); - + const [selectedPlanForRequirements, setSelectedPlanForRequirements] = useState(null); + const [showCriticalRequirements, setShowCriticalRequirements] = useState(false); + const { currentTenant } = useTenantStore(); const tenantId = currentTenant?.id || ''; @@ -35,6 +38,15 @@ const ProcurementPage: React.FC = () => { }); const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId); const { data: criticalRequirements, isLoading: isCriticalLoading } = useCriticalRequirements(tenantId); + + // Get plan requirements for selected plan + const { data: planRequirements, isLoading: isPlanRequirementsLoading } = usePlanRequirements({ + tenant_id: tenantId, + plan_id: selectedPlanForRequirements || '', + status: 'critical' // Only get critical requirements + }, { + enabled: !!selectedPlanForRequirements && !!tenantId + }); const generatePlanMutation = useGenerateProcurementPlan(); const updatePlanStatusMutation = useUpdateProcurementPlanStatus(); @@ -107,6 +119,16 @@ const ProcurementPage: React.FC = () => { setEditFormData({}); }; + const handleShowCriticalRequirements = (planId: string) => { + setSelectedPlanForRequirements(planId); + setShowCriticalRequirements(true); + }; + + const handleCloseCriticalRequirements = () => { + setShowCriticalRequirements(false); + setSelectedPlanForRequirements(null); + }; + if (!tenantId) { return (
@@ -391,6 +413,15 @@ const ProcurementPage: React.FC = () => { } }); + // Show Critical Requirements button + actions.push({ + label: 'Req. Críticos', + icon: AlertCircle, + variant: 'outline' as const, + priority: 'secondary' as const, + onClick: () => handleShowCriticalRequirements(plan.id) + }); + // Tertiary action: Cancel (least prominent, destructive) if (!['completed', 'cancelled'].includes(plan.status)) { actions.push({ diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 1fddd762..42407e57 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -33,6 +33,7 @@ const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage')) // Database pages const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage')); +const ModelsConfigPage = React.lazy(() => import('../pages/app/database/models/ModelsConfigPage')); // Data pages const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage')); @@ -176,6 +177,16 @@ export const AppRouter: React.FC = () => { } /> + + + + + + } + /> {/* Analytics Routes */} 30: + raise ValueError("forecast_days must be between 1 and 30") + + # Generate multi-day forecast using enhanced service + forecast_result = await enhanced_forecasting_service.generate_multi_day_forecast( + tenant_id=tenant_id, + request=request + ) + + if metrics: + metrics.increment_counter("enhanced_multi_day_forecasts_success_total") + + logger.info("Enhanced multi-day forecast generated successfully", + tenant_id=tenant_id, + inventory_product_id=request.inventory_product_id, + forecast_days=len(forecast_result.get("forecasts", []))) + + return MultiDayForecastResponse(**forecast_result) + + except ValueError as e: + if metrics: + metrics.increment_counter("enhanced_multi_day_forecast_validation_errors_total") + logger.error("Enhanced multi-day forecast validation error", + error=str(e), + tenant_id=tenant_id) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + if metrics: + metrics.increment_counter("enhanced_multi_day_forecasts_errors_total") + logger.error("Enhanced multi-day forecast generation failed", + error=str(e), + tenant_id=tenant_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Enhanced multi-day forecast generation failed" + ) + + @router.post("/tenants/{tenant_id}/forecasts/batch", response_model=BatchForecastResponse) @track_execution_time("enhanced_batch_forecast_duration_seconds", "forecasting-service") async def create_enhanced_batch_forecast( diff --git a/services/forecasting/app/schemas/forecasts.py b/services/forecasting/app/schemas/forecasts.py index 0347b459..b795d1ed 100644 --- a/services/forecasting/app/schemas/forecasts.py +++ b/services/forecasting/app/schemas/forecasts.py @@ -95,4 +95,15 @@ class BatchForecastResponse(BaseModel): forecasts: Optional[List[ForecastResponse]] error_message: Optional[str] +class MultiDayForecastResponse(BaseModel): + """Response schema for multi-day forecast results""" + tenant_id: str = Field(..., description="Tenant ID") + inventory_product_id: str = Field(..., description="Inventory product ID") + forecast_start_date: date = Field(..., description="Start date of forecast period") + forecast_days: int = Field(..., description="Number of forecasted days") + forecasts: List[ForecastResponse] = Field(..., description="Daily forecasts") + total_predicted_demand: float = Field(..., description="Total demand across all days") + average_confidence_level: float = Field(..., description="Average confidence across all days") + processing_time_ms: int = Field(..., description="Total processing time") + diff --git a/services/forecasting/app/services/forecasting_service.py b/services/forecasting/app/services/forecasting_service.py index 0475378f..e0fd9ba8 100644 --- a/services/forecasting/app/services/forecasting_service.py +++ b/services/forecasting/app/services/forecasting_service.py @@ -345,7 +345,101 @@ class EnhancedForecastingService: inventory_product_id=request.inventory_product_id, processing_time=processing_time) raise - + + async def generate_multi_day_forecast( + self, + tenant_id: str, + request: ForecastRequest + ) -> Dict[str, Any]: + """ + Generate multiple daily forecasts for the specified period. + """ + start_time = datetime.utcnow() + forecasts = [] + + try: + logger.info("Generating multi-day forecast", + tenant_id=tenant_id, + inventory_product_id=request.inventory_product_id, + forecast_days=request.forecast_days, + start_date=request.forecast_date.isoformat()) + + # Generate a forecast for each day + for day_offset in range(request.forecast_days): + # Calculate the forecast date for this day + current_date = request.forecast_date + if isinstance(current_date, str): + from dateutil.parser import parse + current_date = parse(current_date).date() + + if day_offset > 0: + from datetime import timedelta + current_date = current_date + timedelta(days=day_offset) + + # Create a new request for this specific day + daily_request = ForecastRequest( + inventory_product_id=request.inventory_product_id, + forecast_date=current_date, + forecast_days=1, # Single day for each iteration + location=request.location, + confidence_level=request.confidence_level + ) + + # Generate forecast for this day + daily_forecast = await self.generate_forecast(tenant_id, daily_request) + forecasts.append(daily_forecast) + + # Calculate summary statistics + total_demand = sum(f.predicted_demand for f in forecasts) + avg_confidence = sum(f.confidence_level for f in forecasts) / len(forecasts) + processing_time = int((datetime.utcnow() - start_time).total_seconds() * 1000) + + # Convert forecasts to dictionary format for the response + forecast_dicts = [] + for forecast in forecasts: + forecast_dicts.append({ + "id": forecast.id, + "tenant_id": forecast.tenant_id, + "inventory_product_id": forecast.inventory_product_id, + "location": forecast.location, + "forecast_date": forecast.forecast_date.isoformat() if hasattr(forecast.forecast_date, 'isoformat') else str(forecast.forecast_date), + "predicted_demand": forecast.predicted_demand, + "confidence_lower": forecast.confidence_lower, + "confidence_upper": forecast.confidence_upper, + "confidence_level": forecast.confidence_level, + "model_id": forecast.model_id, + "model_version": forecast.model_version, + "algorithm": forecast.algorithm, + "business_type": forecast.business_type, + "is_holiday": forecast.is_holiday, + "is_weekend": forecast.is_weekend, + "day_of_week": forecast.day_of_week, + "weather_temperature": forecast.weather_temperature, + "weather_precipitation": forecast.weather_precipitation, + "weather_description": forecast.weather_description, + "traffic_volume": forecast.traffic_volume, + "created_at": forecast.created_at.isoformat() if hasattr(forecast.created_at, 'isoformat') else str(forecast.created_at), + "processing_time_ms": forecast.processing_time_ms, + "features_used": forecast.features_used + }) + + return { + "tenant_id": tenant_id, + "inventory_product_id": request.inventory_product_id, + "forecast_start_date": request.forecast_date.isoformat() if hasattr(request.forecast_date, 'isoformat') else str(request.forecast_date), + "forecast_days": request.forecast_days, + "forecasts": forecast_dicts, + "total_predicted_demand": total_demand, + "average_confidence_level": avg_confidence, + "processing_time_ms": processing_time + } + + except Exception as e: + logger.error("Multi-day forecast generation failed", + tenant_id=tenant_id, + error=str(e)) + raise + async def get_forecast_history( self, tenant_id: str,