Imporve the predicciones page
This commit is contained in:
@@ -15,10 +15,11 @@ import {
|
|||||||
DeleteForecastResponse,
|
DeleteForecastResponse,
|
||||||
GetForecastsParams,
|
GetForecastsParams,
|
||||||
ForecastingHealthResponse,
|
ForecastingHealthResponse,
|
||||||
|
MultiDayForecastResponse,
|
||||||
} from '../types/forecasting';
|
} from '../types/forecasting';
|
||||||
|
|
||||||
export class ForecastingService {
|
export class ForecastingService {
|
||||||
private readonly baseUrl = '/forecasts';
|
private readonly baseUrl = '/tenants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a single product forecast
|
* Generate a single product forecast
|
||||||
@@ -29,7 +30,7 @@ export class ForecastingService {
|
|||||||
request: ForecastRequest
|
request: ForecastRequest
|
||||||
): Promise<ForecastResponse> {
|
): Promise<ForecastResponse> {
|
||||||
return apiClient.post<ForecastResponse, ForecastRequest>(
|
return apiClient.post<ForecastResponse, ForecastRequest>(
|
||||||
`/tenants/${tenantId}${this.baseUrl}/single`,
|
`${this.baseUrl}/${tenantId}/forecasts/single`,
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ export class ForecastingService {
|
|||||||
request: BatchForecastRequest
|
request: BatchForecastRequest
|
||||||
): Promise<BatchForecastResponse> {
|
): Promise<BatchForecastResponse> {
|
||||||
return apiClient.post<BatchForecastResponse, BatchForecastRequest>(
|
return apiClient.post<BatchForecastResponse, BatchForecastRequest>(
|
||||||
`/tenants/${tenantId}${this.baseUrl}/batch`,
|
`${this.baseUrl}/${tenantId}/forecasts/batch`,
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +76,7 @@ export class ForecastingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
const url = `/tenants/${tenantId}${this.baseUrl}${queryString ? `?${queryString}` : ''}`;
|
const url = `${this.baseUrl}/${tenantId}/forecasts${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
return apiClient.get<ForecastListResponse>(url);
|
return apiClient.get<ForecastListResponse>(url);
|
||||||
}
|
}
|
||||||
@@ -89,7 +90,7 @@ export class ForecastingService {
|
|||||||
forecastId: string
|
forecastId: string
|
||||||
): Promise<ForecastByIdResponse> {
|
): Promise<ForecastByIdResponse> {
|
||||||
return apiClient.get<ForecastByIdResponse>(
|
return apiClient.get<ForecastByIdResponse>(
|
||||||
`/tenants/${tenantId}${this.baseUrl}/${forecastId}`
|
`${this.baseUrl}/${tenantId}/forecasts/${forecastId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ export class ForecastingService {
|
|||||||
forecastId: string
|
forecastId: string
|
||||||
): Promise<DeleteForecastResponse> {
|
): Promise<DeleteForecastResponse> {
|
||||||
return apiClient.delete<DeleteForecastResponse>(
|
return apiClient.delete<DeleteForecastResponse>(
|
||||||
`/tenants/${tenantId}${this.baseUrl}/${forecastId}`
|
`${this.baseUrl}/${tenantId}/forecasts/${forecastId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +115,21 @@ export class ForecastingService {
|
|||||||
tenantId: string
|
tenantId: string
|
||||||
): Promise<ForecastStatistics> {
|
): Promise<ForecastStatistics> {
|
||||||
return apiClient.get<ForecastStatistics>(
|
return apiClient.get<ForecastStatistics>(
|
||||||
`/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<MultiDayForecastResponse> {
|
||||||
|
return apiClient.post<MultiDayForecastResponse, ForecastRequest>(
|
||||||
|
`${this.baseUrl}/${tenantId}/forecasts/multi-day`,
|
||||||
|
request
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class TrainingService {
|
|||||||
|
|
||||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||||
return apiClient.get<PaginatedResponse<TrainedModelResponse>>(
|
return apiClient.get<PaginatedResponse<TrainedModelResponse>>(
|
||||||
`${this.baseUrl}/${tenantId}/models${queryString}`
|
`${this.baseUrl}/${tenantId}/models/${queryString}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,3 +158,14 @@ export interface ForecastingHealthResponse {
|
|||||||
features: string[];
|
features: string[];
|
||||||
timestamp: 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;
|
||||||
|
}
|
||||||
@@ -88,9 +88,15 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
|
|
||||||
// Process forecast data for chart
|
// Process forecast data for chart
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
|
console.log('🔍 Processing forecast data for chart:', data);
|
||||||
|
|
||||||
const processedData: ChartDataPoint[] = data.map(forecast => {
|
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 {
|
return {
|
||||||
date: forecast.forecast_date,
|
date: dateString,
|
||||||
actualDemand: undefined, // Not available in current forecast response
|
actualDemand: undefined, // Not available in current forecast response
|
||||||
predictedDemand: forecast.predicted_demand,
|
predictedDemand: forecast.predicted_demand,
|
||||||
confidenceLower: forecast.confidence_lower,
|
confidenceLower: forecast.confidence_lower,
|
||||||
@@ -99,23 +105,32 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('📊 Processed chart data:', processedData);
|
||||||
return processedData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
return processedData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// Filter data based on selected period
|
// Filter data based on selected period
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
|
console.log('🔍 Filtering data - selected period:', selectedPeriod);
|
||||||
|
console.log('🔍 Chart data before filtering:', chartData);
|
||||||
|
|
||||||
if (!selectedPeriod.start || !selectedPeriod.end) {
|
if (!selectedPeriod.start || !selectedPeriod.end) {
|
||||||
|
console.log('📊 No period filter, returning all chart data');
|
||||||
return chartData;
|
return chartData;
|
||||||
}
|
}
|
||||||
|
|
||||||
return chartData.filter(point => {
|
const filtered = chartData.filter(point => {
|
||||||
const pointDate = new Date(point.date);
|
const pointDate = new Date(point.date);
|
||||||
return pointDate >= selectedPeriod.start! && pointDate <= selectedPeriod.end!;
|
return pointDate >= selectedPeriod.start! && pointDate <= selectedPeriod.end!;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('📊 Filtered data:', filtered);
|
||||||
|
return filtered;
|
||||||
}, [chartData, selectedPeriod]);
|
}, [chartData, selectedPeriod]);
|
||||||
|
|
||||||
// Update zoomed data when filtered data changes
|
// Update zoomed data when filtered data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('🔍 Setting zoomed data from filtered data:', filteredData);
|
||||||
setZoomedData(filteredData);
|
setZoomedData(filteredData);
|
||||||
}, [filteredData]);
|
}, [filteredData]);
|
||||||
|
|
||||||
@@ -221,8 +236,11 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
// Use filteredData if zoomedData is empty but we have data
|
||||||
if (zoomedData.length === 0) {
|
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 (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -286,7 +304,7 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reset zoom */}
|
{/* Reset zoom */}
|
||||||
{zoomedData.length !== filteredData.length && (
|
{displayData.length !== filteredData.length && (
|
||||||
<Button variant="ghost" size="sm" onClick={handleResetZoom}>
|
<Button variant="ghost" size="sm" onClick={handleResetZoom}>
|
||||||
Restablecer Zoom
|
Restablecer Zoom
|
||||||
</Button>
|
</Button>
|
||||||
@@ -298,28 +316,57 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
<CardBody padding="lg">
|
<CardBody padding="lg">
|
||||||
<div style={{ width: '100%', height }}>
|
<div style={{ width: '100%', height }}>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<ComposedChart data={zoomedData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
<ComposedChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 60 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
<defs>
|
||||||
|
<linearGradient id="demandGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0.05}/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="confidenceGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1}/>
|
||||||
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0.02}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="2 2"
|
||||||
|
stroke="#e5e7eb"
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
horizontal={true}
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
stroke="#6b7280"
|
stroke="#6b7280"
|
||||||
fontSize={12}
|
fontSize={11}
|
||||||
|
tickMargin={8}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={80}
|
||||||
|
interval={0}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return timeframe === 'weekly'
|
return date.toLocaleDateString('es-ES', {
|
||||||
? date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' })
|
month: 'short',
|
||||||
: timeframe === 'monthly'
|
day: 'numeric',
|
||||||
? date.toLocaleDateString('es-ES', { month: 'short', year: '2-digit' })
|
weekday: displayData.length <= 7 ? 'short' : undefined
|
||||||
: date.getFullYear().toString();
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
stroke="#6b7280"
|
stroke="#6b7280"
|
||||||
fontSize={12}
|
fontSize={11}
|
||||||
tickFormatter={(value) => `${value}`}
|
width={60}
|
||||||
|
tickFormatter={(value) => value.toFixed(0)}
|
||||||
|
domain={['dataMin - 5', 'dataMax + 5']}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip />}
|
||||||
|
cursor={{ stroke: '#10b981', strokeWidth: 1, strokeOpacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ paddingTop: '20px' }}
|
||||||
|
iconType="line"
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend />
|
|
||||||
|
|
||||||
{/* Confidence interval area */}
|
{/* Confidence interval area */}
|
||||||
{showConfidenceInterval && (
|
{showConfidenceInterval && (
|
||||||
@@ -328,9 +375,9 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
dataKey="confidenceUpper"
|
dataKey="confidenceUpper"
|
||||||
stackId={1}
|
stackId={1}
|
||||||
stroke="none"
|
stroke="none"
|
||||||
fill="#10b98120"
|
fill="url(#confidenceGradient)"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.4}
|
||||||
name="Intervalo de Confianza Superior"
|
name="Límite Superior"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showConfidenceInterval && (
|
{showConfidenceInterval && (
|
||||||
@@ -340,20 +387,40 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
stackId={1}
|
stackId={1}
|
||||||
stroke="none"
|
stroke="none"
|
||||||
fill="#ffffff"
|
fill="#ffffff"
|
||||||
name="Intervalo de Confianza Inferior"
|
name="Límite Inferior"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Background area for main prediction */}
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="predictedDemand"
|
||||||
|
stroke="none"
|
||||||
|
fill="url(#demandGradient)"
|
||||||
|
fillOpacity={0.2}
|
||||||
|
name="Área de Demanda"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Predicted demand line */}
|
{/* Predicted demand line */}
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="predictedDemand"
|
dataKey="predictedDemand"
|
||||||
stroke="#10b981"
|
stroke="#10b981"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
dot={true}
|
dot={{
|
||||||
dotSize={6}
|
fill: '#10b981',
|
||||||
activeDot={{ r: 8, stroke: '#10b981', strokeWidth: 2 }}
|
strokeWidth: 2,
|
||||||
|
stroke: '#ffffff',
|
||||||
|
r: 4
|
||||||
|
}}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
stroke: '#10b981',
|
||||||
|
strokeWidth: 3,
|
||||||
|
fill: '#ffffff'
|
||||||
|
}}
|
||||||
name="Demanda Predicha"
|
name="Demanda Predicha"
|
||||||
|
connectNulls={false}
|
||||||
/>
|
/>
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -386,18 +453,46 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chart legend */}
|
{/* Enhanced Chart legend and insights */}
|
||||||
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
<div className="mt-6 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-center gap-6 text-sm">
|
||||||
<div className="w-4 h-0.5 bg-green-500"></div>
|
|
||||||
<span className="text-text-secondary">Demanda Predicha</span>
|
|
||||||
</div>
|
|
||||||
{showConfidenceInterval && (
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-2 bg-green-500 bg-opacity-20"></div>
|
<div className="w-4 h-0.5 bg-green-500 rounded"></div>
|
||||||
<span className="text-text-secondary">Intervalo de Confianza</span>
|
<span className="text-text-secondary">Demanda Predicha</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{showConfidenceInterval && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-2 bg-green-500 bg-opacity-20 rounded"></div>
|
||||||
|
<span className="text-text-secondary">Intervalo de Confianza</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
<span className="text-text-secondary">Puntos de Datos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-green-600">
|
||||||
|
{Math.min(...displayData.map(d => d.predictedDemand || 0)).toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Mínimo</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-blue-600">
|
||||||
|
{(displayData.reduce((sum, d) => sum + (d.predictedDemand || 0), 0) / displayData.length).toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Promedio</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-orange-600">
|
||||||
|
{Math.max(...displayData.map(d => d.predictedDemand || 0)).toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Máximo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
680
frontend/src/pages/app/database/models/ModelsConfigPage.tsx
Normal file
680
frontend/src/pages/app/database/models/ModelsConfigPage.tsx
Normal file
@@ -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<IngredientResponse | null>(null);
|
||||||
|
const [showTrainingModal, setShowTrainingModal] = useState(false);
|
||||||
|
const [showModelDetailsModal, setShowModelDetailsModal] = useState(false);
|
||||||
|
const [trainingSettings, setTrainingSettings] = useState<Partial<SingleProductTrainingRequest>>({
|
||||||
|
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<ModelStatus[]>(() => {
|
||||||
|
// 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<string>('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 <Badge variant="secondary">Sin modelo</Badge>;
|
||||||
|
case 'active':
|
||||||
|
return <Badge variant="success">Activo</Badge>;
|
||||||
|
case 'training':
|
||||||
|
return <Badge variant="warning">Entrenando</Badge>;
|
||||||
|
case 'error':
|
||||||
|
return <Badge variant="error">Error</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="secondary">Desconocido</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAccuracyBadge = (accuracy?: number) => {
|
||||||
|
if (!accuracy) return null;
|
||||||
|
|
||||||
|
const variant = accuracy >= 90 ? 'success' : accuracy >= 75 ? 'warning' : 'error';
|
||||||
|
return <Badge variant={variant} size="sm">{accuracy.toFixed(1)}%</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Table columns configuration
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'ingredient',
|
||||||
|
title: 'Ingrediente',
|
||||||
|
render: (_: any, status: ModelStatus) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold">
|
||||||
|
{status.ingredient.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">{status.ingredient.name}</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">{status.ingredient.category}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: 'Estado del Modelo',
|
||||||
|
render: (_: any, status: ModelStatus) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusBadge(status.status)}
|
||||||
|
{status.accuracy && getAccuracyBadge(status.accuracy)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastTrained',
|
||||||
|
title: 'Último Entrenamiento',
|
||||||
|
render: (_: any, status: ModelStatus) => (
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{status.lastTrainingDate
|
||||||
|
? new Date(status.lastTrainingDate).toLocaleDateString('es-ES')
|
||||||
|
: 'Nunca'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: 'Acciones',
|
||||||
|
render: (_: any, status: ModelStatus) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status.hasModel && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewModelDetails(status.ingredient)}
|
||||||
|
leftIcon={<Eye className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Ver detalles
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={status.hasModel ? "outline" : "primary"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStartTraining(status.ingredient)}
|
||||||
|
leftIcon={status.hasModel ? <RotateCcw className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||||
|
disabled={status.isTraining}
|
||||||
|
>
|
||||||
|
{status.hasModel ? 'Reentrenar' : 'Entrenar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ingredientsLoading || modelsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Configuración de Modelos IA"
|
||||||
|
description="Gestiona el entrenamiento y configuración de modelos de predicción para cada ingrediente"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader className="w-8 h-8 animate-spin" />
|
||||||
|
<span className="ml-2">
|
||||||
|
{ingredientsLoading ? 'Cargando ingredientes...' : 'Cargando modelos...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelsError) {
|
||||||
|
console.error('Error loading models:', modelsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Configuración de Modelos IA"
|
||||||
|
description="Gestiona el entrenamiento y configuración de modelos de predicción para cada ingrediente"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{modelStatuses.filter(s => s.hasModel).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Ingredientes con Modelo</div>
|
||||||
|
</div>
|
||||||
|
<Brain className="w-8 h-8 text-[var(--color-primary)]" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{modelStatuses.filter(s => s.status === 'no_model').length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Sin Modelo</div>
|
||||||
|
</div>
|
||||||
|
<AlertCircle className="w-8 h-8 text-[var(--color-warning)]" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{orphanedModels.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Modelos Huérfanos</div>
|
||||||
|
</div>
|
||||||
|
<AlertCircle className="w-8 h-8 text-[var(--color-secondary)]" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{statsError ? 'N/A' : (statistics?.average_accuracy ? `${(100 - statistics.average_accuracy).toFixed(1)}%` : 'N/A')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Precisión Promedio</div>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="w-8 h-8 text-[var(--color-primary)]" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orphaned Models Warning */}
|
||||||
|
{orphanedModels.length > 0 && (
|
||||||
|
<Card className="p-4 bg-orange-50 border-orange-200">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-orange-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-orange-900 mb-1">
|
||||||
|
Modelos Huérfanos Detectados
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-orange-700">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar ingrediente..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-48">
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(value) => setStatusFilter(value as string)}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: 'Todos los estados' },
|
||||||
|
{ value: 'no_model', label: 'Sin modelo' },
|
||||||
|
{ value: 'active', label: 'Activo' },
|
||||||
|
{ value: 'training', label: 'Entrenando' },
|
||||||
|
{ value: 'error', label: 'Error' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Models Table */}
|
||||||
|
<Card>
|
||||||
|
{filteredStatuses.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Brain className="w-12 h-12 text-[var(--color-secondary)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No se encontraron ingredientes
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] text-center">
|
||||||
|
No hay ingredientes que coincidan con los filtros aplicados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
data={filteredStatuses}
|
||||||
|
columns={tableColumns}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Training Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showTrainingModal}
|
||||||
|
onClose={() => setShowTrainingModal(false)}
|
||||||
|
title={`Entrenar Modelo - ${selectedIngredient?.name}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="p-4 bg-blue-50 rounded-lg">
|
||||||
|
<h4 className="font-medium text-blue-900 mb-2">Configuración de Entrenamiento</h4>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
Configure los parámetros para el entrenamiento del modelo de predicción de demanda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Modo de Estacionalidad</label>
|
||||||
|
<Select
|
||||||
|
value={trainingSettings.seasonality_mode || 'additive'}
|
||||||
|
onChange={(value) => setTrainingSettings(prev => ({ ...prev, seasonality_mode: value as any }))}
|
||||||
|
options={[
|
||||||
|
{ value: 'additive', label: 'Aditivo' },
|
||||||
|
{ value: 'multiplicative', label: 'Multiplicativo' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-sm">Patrones Estacionales</h4>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trainingSettings.daily_seasonality || false}
|
||||||
|
onChange={(e) => setTrainingSettings(prev => ({ ...prev, daily_seasonality: e.target.checked }))}
|
||||||
|
className="rounded border-[var(--border-primary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Estacionalidad diaria</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trainingSettings.weekly_seasonality || false}
|
||||||
|
onChange={(e) => setTrainingSettings(prev => ({ ...prev, weekly_seasonality: e.target.checked }))}
|
||||||
|
className="rounded border-[var(--border-primary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Estacionalidad semanal</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trainingSettings.yearly_seasonality || false}
|
||||||
|
onChange={(e) => setTrainingSettings(prev => ({ ...prev, yearly_seasonality: e.target.checked }))}
|
||||||
|
className="rounded border-[var(--border-primary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Estacionalidad anual</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||||
|
<Button variant="outline" onClick={() => setShowTrainingModal(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleTrainModel}
|
||||||
|
isLoading={trainMutation.isPending}
|
||||||
|
leftIcon={<Play className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Iniciar Entrenamiento
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Model Details Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showModelDetailsModal}
|
||||||
|
onClose={() => setShowModelDetailsModal(false)}
|
||||||
|
title={`Detalles del Modelo - ${selectedIngredient?.name}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{selectedIngredient && (
|
||||||
|
<ModelDetailsContent
|
||||||
|
tenantId={tenantId}
|
||||||
|
ingredientId={selectedIngredient.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component for model details content
|
||||||
|
const ModelDetailsContent: React.FC<{
|
||||||
|
tenantId: string;
|
||||||
|
ingredientId: string;
|
||||||
|
}> = ({ tenantId, ingredientId }) => {
|
||||||
|
const { data: activeModel } = useActiveModel(tenantId, ingredientId);
|
||||||
|
|
||||||
|
if (!activeModel) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<AlertCircle className="w-16 h-16 text-[var(--color-warning)] mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2 text-[var(--text-primary)]">No hay modelo disponible</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] max-w-md mx-auto">
|
||||||
|
Este ingrediente no tiene un modelo entrenado disponible. Puedes entrenar uno nuevo usando el botón "Entrenar".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const precision = activeModel.training_metrics?.mape
|
||||||
|
? (100 - activeModel.training_metrics.mape).toFixed(1)
|
||||||
|
: 'N/A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Model Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-gradient-to-br from-green-50 to-green-100 p-6 rounded-xl border border-green-200">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-green-700 mb-1">
|
||||||
|
{precision}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-green-600">Precisión</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-xl border border-blue-200">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-blue-700 mb-1">
|
||||||
|
{activeModel.training_metrics?.mae?.toFixed(2) || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-blue-600">MAE</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl border border-purple-200">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-purple-700 mb-1">
|
||||||
|
{activeModel.training_metrics?.rmse?.toFixed(2) || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-purple-600">RMSE</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Information */}
|
||||||
|
<Card className="p-6 bg-[var(--bg-primary)]">
|
||||||
|
<h4 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
|
||||||
|
<Brain className="w-5 h-5 mr-2 text-[var(--color-primary)]" />
|
||||||
|
Información del Modelo
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide mb-1">
|
||||||
|
Creado
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">
|
||||||
|
{new Date(activeModel.created_at).toLocaleString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide mb-1">
|
||||||
|
Características usadas
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">
|
||||||
|
{activeModel.features_used?.length || 0} variables
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide mb-1">
|
||||||
|
Período de entrenamiento
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">
|
||||||
|
{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'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide mb-1">
|
||||||
|
Hiperparámetros
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">
|
||||||
|
{Object.keys(activeModel.hyperparameters || {}).length} configurados
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Features Used */}
|
||||||
|
{activeModel.features_used && activeModel.features_used.length > 0 && (
|
||||||
|
<Card className="p-6 bg-[var(--bg-primary)]">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-[var(--text-primary)] flex items-center">
|
||||||
|
<TrendingUp className="w-5 h-5 mr-2 text-[var(--color-primary)]" />
|
||||||
|
Características del Modelo
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
{activeModel.features_used.map((feature: string, index: number) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg px-3 py-2 text-center"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{feature.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Training Performance */}
|
||||||
|
{activeModel.training_metrics && (
|
||||||
|
<Card className="p-6 bg-[var(--bg-primary)]">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-[var(--text-primary)] flex items-center">
|
||||||
|
<CheckCircle className="w-5 h-5 mr-2 text-[var(--color-success)]" />
|
||||||
|
Métricas de Rendimiento
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
|
||||||
|
<div className="text-2xl font-bold text-[var(--color-success)] mb-1">
|
||||||
|
{precision}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Precisión</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">
|
||||||
|
{activeModel.training_metrics.mae?.toFixed(2) || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">MAE</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">
|
||||||
|
{activeModel.training_metrics.rmse?.toFixed(2) || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">RMSE</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">
|
||||||
|
{activeModel.training_metrics.r2_score?.toFixed(3) || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">R²</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelsConfigPage;
|
||||||
1
frontend/src/pages/app/database/models/index.ts
Normal file
1
frontend/src/pages/app/database/models/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as ModelsConfigPage } from './ModelsConfigPage';
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useProcurementPlans,
|
useProcurementPlans,
|
||||||
useCurrentProcurementPlan,
|
useCurrentProcurementPlan,
|
||||||
useCriticalRequirements,
|
useCriticalRequirements,
|
||||||
|
usePlanRequirements,
|
||||||
useGenerateProcurementPlan,
|
useGenerateProcurementPlan,
|
||||||
useUpdateProcurementPlanStatus,
|
useUpdateProcurementPlanStatus,
|
||||||
useTriggerDailyScheduler
|
useTriggerDailyScheduler
|
||||||
@@ -22,6 +23,8 @@ const ProcurementPage: React.FC = () => {
|
|||||||
const [selectedPlan, setSelectedPlan] = useState<any>(null);
|
const [selectedPlan, setSelectedPlan] = useState<any>(null);
|
||||||
const [editingPlan, setEditingPlan] = useState<any>(null);
|
const [editingPlan, setEditingPlan] = useState<any>(null);
|
||||||
const [editFormData, setEditFormData] = useState<any>({});
|
const [editFormData, setEditFormData] = useState<any>({});
|
||||||
|
const [selectedPlanForRequirements, setSelectedPlanForRequirements] = useState<string | null>(null);
|
||||||
|
const [showCriticalRequirements, setShowCriticalRequirements] = useState(false);
|
||||||
|
|
||||||
const { currentTenant } = useTenantStore();
|
const { currentTenant } = useTenantStore();
|
||||||
const tenantId = currentTenant?.id || '';
|
const tenantId = currentTenant?.id || '';
|
||||||
@@ -36,6 +39,15 @@ const ProcurementPage: React.FC = () => {
|
|||||||
const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId);
|
const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId);
|
||||||
const { data: criticalRequirements, isLoading: isCriticalLoading } = useCriticalRequirements(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 generatePlanMutation = useGenerateProcurementPlan();
|
||||||
const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
|
const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
|
||||||
const triggerSchedulerMutation = useTriggerDailyScheduler();
|
const triggerSchedulerMutation = useTriggerDailyScheduler();
|
||||||
@@ -107,6 +119,16 @@ const ProcurementPage: React.FC = () => {
|
|||||||
setEditFormData({});
|
setEditFormData({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShowCriticalRequirements = (planId: string) => {
|
||||||
|
setSelectedPlanForRequirements(planId);
|
||||||
|
setShowCriticalRequirements(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCriticalRequirements = () => {
|
||||||
|
setShowCriticalRequirements(false);
|
||||||
|
setSelectedPlanForRequirements(null);
|
||||||
|
};
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -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)
|
// Tertiary action: Cancel (least prominent, destructive)
|
||||||
if (!['completed', 'cancelled'].includes(plan.status)) {
|
if (!['completed', 'cancelled'].includes(plan.status)) {
|
||||||
actions.push({
|
actions.push({
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'))
|
|||||||
|
|
||||||
// Database pages
|
// Database pages
|
||||||
const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage'));
|
const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage'));
|
||||||
|
const ModelsConfigPage = React.lazy(() => import('../pages/app/database/models/ModelsConfigPage'));
|
||||||
|
|
||||||
// Data pages
|
// Data pages
|
||||||
const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage'));
|
const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage'));
|
||||||
@@ -176,6 +177,16 @@ export const AppRouter: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/app/database/models"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<ModelsConfigPage />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Analytics Routes */}
|
{/* Analytics Routes */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -330,6 +330,16 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
showInBreadcrumbs: true,
|
showInBreadcrumbs: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/app/database/models',
|
||||||
|
name: 'ModelsConfig',
|
||||||
|
component: 'ModelsConfigPage',
|
||||||
|
title: 'Modelos IA',
|
||||||
|
icon: 'training',
|
||||||
|
requiresAuth: true,
|
||||||
|
showInNavigation: true,
|
||||||
|
showInBreadcrumbs: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ async def proxy_tenant_models(request: Request, tenant_id: str = Path(...), path
|
|||||||
target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/")
|
target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/")
|
||||||
return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id)
|
return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
@router.api_route("/{tenant_id}/statistics", methods=["GET", "OPTIONS"])
|
||||||
|
async def proxy_tenant_statistics(request: Request, tenant_id: str = Path(...)):
|
||||||
|
"""Proxy tenant statistics requests to training service"""
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/statistics"
|
||||||
|
return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id)
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# TENANT-SCOPED FORECASTING SERVICE ENDPOINTS
|
# TENANT-SCOPED FORECASTING SERVICE ENDPOINTS
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import uuid
|
|||||||
from app.services.forecasting_service import EnhancedForecastingService
|
from app.services.forecasting_service import EnhancedForecastingService
|
||||||
from app.schemas.forecasts import (
|
from app.schemas.forecasts import (
|
||||||
ForecastRequest, ForecastResponse, BatchForecastRequest,
|
ForecastRequest, ForecastResponse, BatchForecastRequest,
|
||||||
BatchForecastResponse
|
BatchForecastResponse, MultiDayForecastResponse
|
||||||
)
|
)
|
||||||
from shared.auth.decorators import (
|
from shared.auth.decorators import (
|
||||||
get_current_user_dep,
|
get_current_user_dep,
|
||||||
@@ -89,6 +89,70 @@ async def create_enhanced_single_forecast(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/forecasts/multi-day", response_model=MultiDayForecastResponse)
|
||||||
|
@track_execution_time("enhanced_multi_day_forecast_duration_seconds", "forecasting-service")
|
||||||
|
async def create_enhanced_multi_day_forecast(
|
||||||
|
request: ForecastRequest,
|
||||||
|
tenant_id: str = Path(..., description="Tenant ID"),
|
||||||
|
request_obj: Request = None,
|
||||||
|
enhanced_forecasting_service: EnhancedForecastingService = Depends(get_enhanced_forecasting_service)
|
||||||
|
):
|
||||||
|
"""Generate multiple daily forecasts for the specified period using enhanced repository pattern"""
|
||||||
|
metrics = get_metrics_collector(request_obj)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Generating enhanced multi-day forecast",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
inventory_product_id=request.inventory_product_id,
|
||||||
|
forecast_days=request.forecast_days,
|
||||||
|
forecast_date=request.forecast_date.isoformat())
|
||||||
|
|
||||||
|
# Record metrics
|
||||||
|
if metrics:
|
||||||
|
metrics.increment_counter("enhanced_multi_day_forecasts_total")
|
||||||
|
|
||||||
|
# Validate forecast_days parameter
|
||||||
|
if request.forecast_days <= 0 or request.forecast_days > 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)
|
@router.post("/tenants/{tenant_id}/forecasts/batch", response_model=BatchForecastResponse)
|
||||||
@track_execution_time("enhanced_batch_forecast_duration_seconds", "forecasting-service")
|
@track_execution_time("enhanced_batch_forecast_duration_seconds", "forecasting-service")
|
||||||
async def create_enhanced_batch_forecast(
|
async def create_enhanced_batch_forecast(
|
||||||
|
|||||||
@@ -95,4 +95,15 @@ class BatchForecastResponse(BaseModel):
|
|||||||
forecasts: Optional[List[ForecastResponse]]
|
forecasts: Optional[List[ForecastResponse]]
|
||||||
error_message: Optional[str]
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,100 @@ class EnhancedForecastingService:
|
|||||||
processing_time=processing_time)
|
processing_time=processing_time)
|
||||||
raise
|
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(
|
async def get_forecast_history(
|
||||||
self,
|
self,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user