From 508f4569b94718247883aa75a4e33f59570ca362 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Tue, 9 Dec 2025 10:21:41 +0100 Subject: [PATCH] Fix and UI imporvements --- frontend/package-lock.json | 34 ++ frontend/package.json | 2 + .../src/components/maps/DistributionMap.tsx | 195 +++++----- frontend/src/hooks/useEventNotifications.ts | 1 + .../distribution/DistributionPage.tsx | 343 +++++++++++------- frontend/src/pages/public/DemoPage.tsx | 215 ++++++++--- .../app/consumer/event_consumer.py | 2 +- .../app/enrichment/business_impact.py | 15 +- .../app/enrichment/urgency_analyzer.py | 35 ++ .../app/services/enrichment_orchestrator.py | 2 +- .../app/services/cloning_strategies.py | 107 ++++-- .../app/services/session_manager.py | 24 +- .../scripts/seed_dashboard_comprehensive.py | 3 +- .../demo/seed_demo_distribution_history.py | 43 ++- services/orchestrator/app/core/config.py | 2 + .../app/services/orchestrator_service.py | 45 ++- .../app/services/delivery_tracking_service.py | 33 +- .../production/app/api/production_batches.py | 2 +- shared/messaging/__init__.py | 17 +- .../events.py => messaging/schemas.py} | 47 ++- shared/schemas/alert_types.py | 276 -------------- shared/schemas/event_classification.py | 343 ------------------ 22 files changed, 833 insertions(+), 953 deletions(-) rename shared/{schemas/events.py => messaging/schemas.py} (77%) delete mode 100644 shared/schemas/alert_types.py delete mode 100644 shared/schemas/event_classification.py diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e2d6372..3dc8df18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "i18next": "^23.7.0", "i18next-icu": "^2.4.1", "immer": "^10.0.3", + "leaflet": "^1.9.4", "lucide-react": "^0.294.0", "papaparse": "^5.4.1", "react": "^18.2.0", @@ -43,6 +44,7 @@ "react-hook-form": "^7.48.0", "react-hot-toast": "^2.4.1", "react-i18next": "^13.5.0", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.20.0", "recharts": "^2.10.0", "tailwind-merge": "^2.1.0", @@ -4038,6 +4040,17 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -11597,6 +11610,13 @@ "node": ">=14.0.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause", + "peer": true + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -13415,6 +13435,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 783e163f..9fb4df08 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,6 +54,7 @@ "i18next": "^23.7.0", "i18next-icu": "^2.4.1", "immer": "^10.0.3", + "leaflet": "^1.9.4", "lucide-react": "^0.294.0", "papaparse": "^5.4.1", "react": "^18.2.0", @@ -64,6 +65,7 @@ "react-hook-form": "^7.48.0", "react-hot-toast": "^2.4.1", "react-i18next": "^13.5.0", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.20.0", "recharts": "^2.10.0", "tailwind-merge": "^2.1.0", diff --git a/frontend/src/components/maps/DistributionMap.tsx b/frontend/src/components/maps/DistributionMap.tsx index 3972fa5f..96b8660b 100644 --- a/frontend/src/components/maps/DistributionMap.tsx +++ b/frontend/src/components/maps/DistributionMap.tsx @@ -1,9 +1,12 @@ /* * Distribution Map Component for Enterprise Dashboard - * Shows delivery routes and shipment status across the network + * Shows delivery routes and shipment status across the network with real Leaflet map */ import React, { useState } from 'react'; +import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card'; import { Badge } from '../ui/Badge'; import { Button } from '../ui/Button'; @@ -22,6 +25,14 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +// Fix for default marker icons in Leaflet +delete (L.Icon.Default.prototype as any)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png', + iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png', + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', +}); + interface RoutePoint { tenant_id: string; name: string; @@ -32,6 +43,7 @@ interface RoutePoint { estimated_arrival?: string; actual_arrival?: string; sequence: number; + id?: string; } interface RouteData { @@ -40,7 +52,7 @@ interface RouteData { total_distance_km: number; estimated_duration_minutes: number; status: 'planned' | 'in_progress' | 'completed' | 'cancelled'; - route_points?: RoutePoint[]; + route_sequence?: RoutePoint[]; // Backend returns route_sequence, not route_points } interface ShipmentStatusData { @@ -55,13 +67,22 @@ interface DistributionMapProps { shipments?: ShipmentStatusData; } +// Helper function to create custom markers +const createRouteMarker = (color: string, number: number) => { + return L.divIcon({ + className: 'custom-route-marker', + html: `
${number}
`, + iconSize: [32, 32], + iconAnchor: [16, 16] + }); +}; + const DistributionMap: React.FC = ({ routes = [], shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 } }) => { const { t } = useTranslation('dashboard'); const [selectedRoute, setSelectedRoute] = useState(null); - const [showAllRoutes, setShowAllRoutes] = useState(true); const renderMapVisualization = () => { if (!routes || routes.length === 0) { @@ -87,9 +108,12 @@ const DistributionMap: React.FC = ({ ); } - // Find active routes (in_progress or planned for today) + // Find active routes with GPS data const activeRoutes = routes.filter(route => - route.status === 'in_progress' || route.status === 'planned' + (route.status === 'in_progress' || route.status === 'planned') && + route.route_sequence && + route.route_sequence.length > 0 && + route.route_sequence.every(point => point.latitude && point.longitude) ); if (activeRoutes.length === 0) { @@ -115,113 +139,108 @@ const DistributionMap: React.FC = ({ ); } - // Enhanced visual representation with improved styling + // Calculate center point (average of all route points) + const allPoints = activeRoutes.flatMap(route => route.route_sequence || []); + const centerLat = allPoints.reduce((sum, p) => sum + p.latitude, 0) / allPoints.length; + const centerLng = allPoints.reduce((sum, p) => sum + p.longitude, 0) / allPoints.length; + + // Real Leaflet map with routes return ( -
- {/* Bakery-themed pattern overlay */} -
+
+ + - {/* Central Info Display */} -
-
-
- -
-
- {t('enterprise.distribution_map')} -
-
- {activeRoutes.length} {t('enterprise.active_routes')} -
-
-
+ {/* Render each route */} + {activeRoutes.map((route, routeIdx) => { + const points = route.route_sequence || []; + const routeColor = route.status === 'in_progress' ? '#3b82f6' : '#f59e0b'; - {/* Glassmorphism Route Info Cards */} -
- {activeRoutes.slice(0, 3).map((route, index) => ( -
-
-
[p.latitude, p.longitude]); + + return ( + + {/* Route polyline */} + - -
-
-
- {t('enterprise.route')} {route.route_number} -
-
- {route.total_distance_km.toFixed(1)} km • {Math.ceil(route.estimated_duration_minutes / 60)}h -
-
-
-
- ))} - {activeRoutes.length > 3 && ( -
-
- +{activeRoutes.length - 3} more -
-
- )} -
+ /> - {/* Status Legend */} + {/* Route stop markers */} + {points.map((point, idx) => { + const markerColor = point.status === 'delivered' ? '#22c55e' : + point.status === 'in_transit' ? '#3b82f6' : + point.status === 'failed' ? '#ef4444' : '#f59e0b'; + + return ( + + +
+
{point.name}
+
{point.address}
+
+
Route: {route.route_number}
+
Stop {point.sequence} of {points.length}
+
Status: {point.status}
+
+
+
+
+ ); + })} + + ); + })} +
+ + {/* Status Legend Overlay */}
+
+ {activeRoutes.length} {t('enterprise.active_routes')} +
-
+
- {t('enterprise.pending')}: {shipments.pending} + {t('enterprise.delivered')}: {shipments.delivered}
-
+
{t('enterprise.in_transit')}: {shipments.in_transit}
-
+
- {t('enterprise.delivered')}: {shipments.delivered} + {t('enterprise.pending')}: {shipments.pending}
{shipments.failed > 0 && (
-
+
{t('enterprise.failed')}: {shipments.failed} @@ -437,9 +456,9 @@ const DistributionMap: React.FC = ({
{/* Timeline of Stops */} - {route.route_points && route.route_points.length > 0 && ( + {route.route_sequence && route.route_sequence.length > 0 && (
- {route.route_points.map((point, idx) => { + {route.route_sequence.map((point, idx) => { const getPointStatusColor = (status: string) => { switch (status) { case 'delivered': diff --git a/frontend/src/hooks/useEventNotifications.ts b/frontend/src/hooks/useEventNotifications.ts index d9e3861c..c6508f0b 100644 --- a/frontend/src/hooks/useEventNotifications.ts +++ b/frontend/src/hooks/useEventNotifications.ts @@ -189,6 +189,7 @@ export function useEventNotifications(config: UseNotificationsConfig = {}): UseE notifications, recentNotifications, isLoading: isLoading || !isConnected, + isConnected, // Added this missing return property clearNotifications, }; } diff --git a/frontend/src/pages/app/operations/distribution/DistributionPage.tsx b/frontend/src/pages/app/operations/distribution/DistributionPage.tsx index 6ad99538..9fcea957 100644 --- a/frontend/src/pages/app/operations/distribution/DistributionPage.tsx +++ b/frontend/src/pages/app/operations/distribution/DistributionPage.tsx @@ -6,13 +6,12 @@ import { Package, MapPin, Calendar, - ArrowRight, Search, Filter, MoreVertical, - Clock, CheckCircle, - AlertTriangle + AlertTriangle, + X } from 'lucide-react'; import { Button, @@ -22,8 +21,10 @@ import { CardHeader, CardTitle, Badge, - Input + Input, + Tabs, } from '../../../../components/ui'; +import { TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs'; import { PageHeader } from '../../../../components/layout'; import { useTenant } from '../../../../stores/tenant.store'; import { useDistributionOverview } from '../../../../api/hooks/useEnterpriseDashboard'; @@ -118,69 +119,60 @@ const DistributionPage: React.FC = () => { /> {/* Main Content Areas */} -
- +
{/* Tabs Navigation */} -
- - - -
+ setActiveTab(value)} + className="w-full" + > + + + {t('operations:distribution.tabs.overview', 'Vista General')} + + + {t('operations:distribution.tabs.routes', 'Listado de Rutas')} + + + {t('operations:distribution.tabs.shipments', 'Listado de Envíos')} + + - {/* Content based on Active Tab */} - {activeTab === 'overview' && ( -
+ {/* Content based on Active Tab */} + {/* Map Section */} - - + +
-
-
- +
+
+
- {t('operations:map.title', 'Mapa de Distribución')} -

Visualización en tiempo real de la flota

+ + {t('operations:map.title', 'Mapa de Distribución')} + +

+ {t('operations:map.description', 'Visualización en tiempo real de la flota')} +

- +
- En Vivo + {t('operations:map.live', 'En Vivo')}
-
- +
+
+ +
@@ -189,7 +181,9 @@ const DistributionPage: React.FC = () => {
- Rutas en Progreso + + {t('operations:distribution.active_routes', 'Rutas en Progreso')} + {distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length > 0 ? ( @@ -197,101 +191,190 @@ const DistributionPage: React.FC = () => { {distributionData.route_sequences .filter((r: any) => r.status === 'in_progress') .map((route: any) => ( -
+
-
- +
+
-

Ruta {route.route_number}

-

{route.formatted_driver_name || 'Sin conductor asignado'}

+

+ {t('operations:distribution.route_prefix', 'Ruta')} {route.route_number} +

+

+ {route.formatted_driver_name || t('operations:distribution.no_driver', 'Sin conductor asignado')} +

- En Ruta + + {t('operations:distribution.status.in_progress', 'En Ruta')} +
))}
) : ( -
- No hay rutas en progreso actualmente. +
+ {t('operations:distribution.no_active_routes', 'No hay rutas en progreso actualmente.')} +
+ )} + + + + + + + {t('operations:distribution.pending_deliveries', 'Entregas Pendientes')} + + + + {distributionData?.status_counts?.pending > 0 ? ( +
+
+
+
+ +
+
+

+ {t('operations:distribution.pending_count', 'Entregas Pendientes')} +

+

+ {t('operations:distribution.pending_desc', 'Aún por distribuir')} +

+
+
+ + {distributionData.status_counts.pending} + +
+
+ ) : ( +
+ {t('operations:distribution.no_pending', 'No hay entregas pendientes.')}
)}
-
- )} + - {activeTab === 'routes' && ( - - -
- Listado de Rutas -
- } - className="w-64" - /> - + + + +
+
+ + {t('operations:distribution.routes_list', 'Listado de Rutas')} + +

+ {t('operations:distribution.routes_desc', 'Gestión y seguimiento de rutas de distribución')} +

+
+
+ } + className="w-full sm:w-64" + /> + +
-
- - - {(distributionData?.route_sequences?.length || 0) > 0 ? ( -
- - - - - - - - - - - - - {distributionData.route_sequences.map((route: any) => ( - - - - - - - + + + {(distributionData?.route_sequences?.length || 0) > 0 ? ( +
+
RutaEstadoDistanciaDuración Est.ParadasAcciones
{route.route_number} - - {route.status} - - {route.total_distance_km?.toFixed(1) || '-'} km{route.estimated_duration_minutes || '-'} min{route.route_points?.length || 0} -
+ + + + + + + + - ))} - -
{t('operations:distribution.table.route', 'Ruta')}{t('operations:distribution.table.status', 'Estado')}{t('operations:distribution.table.distance', 'Distancia')}{t('operations:distribution.table.duration', 'Duración Est.')}{t('operations:distribution.table.stops', 'Paradas')}{t('operations:distribution.table.actions', 'Acciones')}
-
- ) : ( -
-

No se encontraron rutas para esta fecha.

-
- )} -
- - )} + + + {distributionData.route_sequences.map((route: any) => ( + + + {t('operations:distribution.route_prefix', 'Ruta')} {route.route_number} + + + + {route.status} + + + + {route.total_distance_km?.toFixed(1) || '-'} km + + + {route.estimated_duration_minutes || '-'} min + + + {route.route_points?.length || 0} + + +
+ ) : ( +
+

+ {t('operations:distribution.no_routes_found', 'No se encontraron rutas para esta fecha.')} +

+
+ )} +
+
+ - {/* Similar structure for Shipments tab, simplified for now */} - {activeTab === 'shipments' && ( -
- -

Gestión de Envíos

-

Funcionalidad de listado detallado de envíos próximamente.

-
- )} + {/* Similar structure for Shipments tab, simplified for now */} + + + + + {t('operations:distribution.shipments_list', 'Gestión de Envíos')} + +

+ {t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')} +

+
+ +
+ +

+ {t('operations:distribution.shipments_title', 'Gestión de Envíos')} +

+

+ {t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')} +

+
+
+
+
+
); diff --git a/frontend/src/pages/public/DemoPage.tsx b/frontend/src/pages/public/DemoPage.tsx index 9bd2cd64..1fdf0acb 100644 --- a/frontend/src/pages/public/DemoPage.tsx +++ b/frontend/src/pages/public/DemoPage.tsx @@ -58,6 +58,7 @@ const DemoPage = () => { const [creationError, setCreationError] = useState(''); const [estimatedProgress, setEstimatedProgress] = useState(0); const [progressStartTime, setProgressStartTime] = useState(null); + const [estimatedRemainingSeconds, setEstimatedRemainingSeconds] = useState(null); // BUG-010 FIX: State for partial status warning const [partialWarning, setPartialWarning] = useState<{ @@ -227,19 +228,19 @@ const DemoPage = () => { } catch (error) { console.error('Error creating demo:', error); - setCreationError('Error al iniciar la demo. Por favor, inténtalo de nuevo.'); - } finally { setCreatingTier(null); setProgressStartTime(null); setEstimatedProgress(0); - // Reset progress setCloneProgress({ parent: 0, children: [0, 0, 0], distribution: 0, overall: 0 }); + setCreationError('Error al iniciar la demo. Por favor, inténtalo de nuevo.'); } + // NOTE: State reset moved to navigation callback and error handlers + // to prevent modal from disappearing before redirect }; const pollForSessionStatus = async (sessionId, tier, sessionData) => { @@ -287,17 +288,33 @@ const DemoPage = () => { const statusData = await statusResponse.json(); + // Capture estimated remaining time from backend + if (statusData.estimated_remaining_seconds !== undefined) { + setEstimatedRemainingSeconds(statusData.estimated_remaining_seconds); + } + // Update progress based on actual backend status updateProgressFromBackendStatus(statusData, tier); // BUG-010 FIX: Handle ready status separately from partial if (statusData.status === 'ready') { - // Full success - navigate immediately + // Full success - set to 100% and navigate after delay clearInterval(progressInterval); + setCloneProgress(prev => ({ ...prev, overall: 100 })); setTimeout(() => { + // Reset state before navigation + setCreatingTier(null); + setProgressStartTime(null); + setEstimatedProgress(0); + setCloneProgress({ + parent: 0, + children: [0, 0, 0], + distribution: 0, + overall: 0 + }); // Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier navigate('/app/dashboard'); - }, 1000); + }, 1500); // Increased from 1000ms to show 100% completion return; } else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') { // BUG-010 FIX: Show warning modal for partial status @@ -313,6 +330,15 @@ const DemoPage = () => { return; } else if (statusData.status === 'FAILED' || statusData.status === 'failed') { clearInterval(progressInterval); + setCreatingTier(null); + setProgressStartTime(null); + setEstimatedProgress(0); + setCloneProgress({ + parent: 0, + children: [0, 0, 0], + distribution: 0, + overall: 0 + }); setCreationError('Error al clonar los datos de demo. Por favor, inténtalo de nuevo.'); return; } @@ -343,6 +369,15 @@ const DemoPage = () => { } catch (error) { clearInterval(progressInterval); console.error('Error polling for status:', error); + setCreatingTier(null); + setProgressStartTime(null); + setEstimatedProgress(0); + setCloneProgress({ + parent: 0, + children: [0, 0, 0], + distribution: 0, + overall: 0 + }); setCreationError('Error verificando el estado de la demo. Por favor, inténtalo de nuevo.'); } finally { // Clean up abort controller reference @@ -466,7 +501,9 @@ const DemoPage = () => { if (progress.parent && progress.children && progress.distribution !== undefined) { // This looks like an enterprise results structure from the end of cloning // Calculate progress based on parent, children, and distribution status - if (progress.parent.overall_status === 'ready' || progress.parent.overall_status === 'partial') { + // FIX 1: Handle both "completed" and "ready" for parent status + const parentStatus = progress.parent.overall_status; + if (parentStatus === 'ready' || parentStatus === 'completed' || parentStatus === 'partial') { parentProgress = 100; } else if (progress.parent.overall_status === 'pending') { parentProgress = 50; // Increased from 25 for better perceived progress @@ -482,9 +519,11 @@ const DemoPage = () => { if (progress.children && progress.children.length > 0) { childrenProgressArray = progress.children.map((child: any) => { - if (child.status === 'ready' || child.status === 'completed') return 100; - if (child.status === 'partial') return 75; - if (child.status === 'pending') return 30; + // FIX 2: Handle both status types for children + const childStatus = child.status || child.overall_status; + if (childStatus === 'ready' || childStatus === 'completed') return 100; + if (childStatus === 'partial') return 75; + if (childStatus === 'pending') return 30; return 0; }); const avgChildrenProgress = childrenProgressArray.reduce((a, b) => a + b, 0) / childrenProgressArray.length; @@ -499,15 +538,22 @@ const DemoPage = () => { } if (progress.distribution) { - if (progress.distribution.status === 'ready' || progress.distribution.status === 'completed') { + // FIX 3: Handle both status types for distribution + const distStatus = progress.distribution.status || progress.distribution.overall_status; + if (distStatus === 'ready' || distStatus === 'completed') { distributionProgress = 100; - } else if (progress.distribution.status === 'pending') { + } else if (distStatus === 'pending') { distributionProgress = 50; } else { - distributionProgress = progress.distribution.status === 'failed' ? 100 : 75; + distributionProgress = distStatus === 'failed' ? 100 : 75; } backendProgress = Math.round(backendProgress * 0.8 + distributionProgress * 0.2); } + + // FIX 4: Allow 100% progress when all components complete + if (parentProgress === 100 && childrenProgressArray.every(p => p === 100) && distributionProgress === 100) { + backendProgress = 100; + } } else { // If it's not the enterprise result structure, fall back to service-based calculation const services = progress || {}; @@ -525,8 +571,9 @@ const DemoPage = () => { distributionProgress = backendProgress * 0.8; } - // Use the maximum of backend progress and estimated progress to prevent backtracking - const overallProgress = Math.max(Math.min(95, backendProgress), estimatedProgress); + // FIX 5: Don't cap at 95% when backend reports 100% + const cappedBackendProgress = backendProgress === 100 ? 100 : Math.min(95, backendProgress); + const overallProgress = Math.max(cappedBackendProgress, estimatedProgress); setCloneProgress({ parent: Math.max(parentProgress, estimatedProgress * 0.9), @@ -681,61 +728,127 @@ const DemoPage = () => { { }} - size="md" + size="lg" > +
+ Configurando Tu Demo +
+ } showCloseButton={false} /> -
-
- Progreso total - {cloneProgress.overall}% -
-
-
-
- -
- {getLoadingMessage(creatingTier, cloneProgress.overall)} -
- - {creatingTier === 'enterprise' && ( -
-
- Obrador Central - {cloneProgress.parent}% +
+ {/* Overall Progress Section */} +
+
+ Progreso Total + {cloneProgress.overall}% +
+
+
+
+
+ + {estimatedRemainingSeconds !== null && estimatedRemainingSeconds > 0 && ( +
+ ~{estimatedRemainingSeconds}s restantes +
+ )} + +
+ {getLoadingMessage(creatingTier, cloneProgress.overall)} +
+
+ + {/* Enterprise Detailed Progress */} + {creatingTier === 'enterprise' && ( +
+ {/* Parent Tenant */} +
+
+
+
+ Obrador Central +
+ {cloneProgress.parent}% +
+
+
+
+
+ + {/* Child Outlets */}
{cloneProgress.children.map((progress, index) => ( -
-
Outlet {index + 1}
-
+
+
+ Outlet {index + 1} + {progress}% +
+
-
{progress}%
))}
-
- Distribución - {cloneProgress.distribution}% -
-
-
+ + {/* Distribution System */} +
+
+
+
+ Distribución +
+ {cloneProgress.distribution}% +
+
+
+
)} + + {/* Professional Progress Indicator */} + {creatingTier === 'professional' && cloneProgress.overall < 100 && ( +
+
+
+
+
+
+

+ Procesando servicios en paralelo... +

+
+ )} + + {/* Information Box */} +
+

+ {creatingTier === 'enterprise' + ? 'Creando obrador central, outlets y sistema de distribución...' + : 'Personalizando tu panadería con datos reales...'} +

+
diff --git a/services/alert_processor/app/consumer/event_consumer.py b/services/alert_processor/app/consumer/event_consumer.py index 4dc9e53e..68cce9ad 100644 --- a/services/alert_processor/app/consumer/event_consumer.py +++ b/services/alert_processor/app/consumer/event_consumer.py @@ -12,7 +12,7 @@ import structlog from app.core.config import settings from app.core.database import AsyncSessionLocal -from shared.schemas.events import MinimalEvent +from shared.messaging import MinimalEvent from app.services.enrichment_orchestrator import EnrichmentOrchestrator from app.repositories.event_repository import EventRepository from shared.clients.notification_client import create_notification_client diff --git a/services/alert_processor/app/enrichment/business_impact.py b/services/alert_processor/app/enrichment/business_impact.py index 1d6cb4e0..e80a5f02 100644 --- a/services/alert_processor/app/enrichment/business_impact.py +++ b/services/alert_processor/app/enrichment/business_impact.py @@ -115,9 +115,18 @@ class BusinessImpactAnalyzer: """Analyze impact of procurement-related alerts""" impact = {} - # PO amount as financial impact - po_amount = metadata.get("po_amount", metadata.get("total_amount", 0)) - impact["financial_impact_eur"] = float(po_amount) + # Extract potential_loss_eur from reasoning_data.parameters + reasoning_data = metadata.get("reasoning_data", {}) + parameters = reasoning_data.get("parameters", {}) + potential_loss_eur = parameters.get("potential_loss_eur") + + # Use potential loss from reasoning as financial impact (what's at risk) + # Fallback to PO amount only if reasoning data is not available + if potential_loss_eur is not None: + impact["financial_impact_eur"] = float(potential_loss_eur) + else: + po_amount = metadata.get("po_amount", metadata.get("total_amount", 0)) + impact["financial_impact_eur"] = float(po_amount) # Days overdue affects customer impact days_overdue = metadata.get("days_overdue", 0) diff --git a/services/alert_processor/app/enrichment/urgency_analyzer.py b/services/alert_processor/app/enrichment/urgency_analyzer.py index 98a510fa..44b5d3d4 100644 --- a/services/alert_processor/app/enrichment/urgency_analyzer.py +++ b/services/alert_processor/app/enrichment/urgency_analyzer.py @@ -48,6 +48,9 @@ class UrgencyAnalyzer: elif "delivery" in event_type or "overdue" in event_type: urgency.update(self._analyze_delivery_urgency(metadata)) + elif "po_approval" in event_type: + urgency.update(self._analyze_po_approval_urgency(metadata)) + # Check for explicit deadlines if "required_delivery_date" in metadata: urgency.update(self._calculate_deadline_urgency(metadata["required_delivery_date"])) @@ -115,6 +118,38 @@ class UrgencyAnalyzer: return urgency + def _analyze_po_approval_urgency(self, metadata: Dict[str, Any]) -> dict: + """ + Analyze urgency for PO approval alerts. + + Uses stockout time (when you run out of stock) instead of delivery date + to determine true urgency. + """ + urgency = {} + + # Extract min_depletion_hours from reasoning_data.parameters + reasoning_data = metadata.get("reasoning_data", {}) + parameters = reasoning_data.get("parameters", {}) + min_depletion_hours = parameters.get("min_depletion_hours") + + if min_depletion_hours is not None: + urgency["hours_until_consequence"] = max(0, round(min_depletion_hours, 1)) + urgency["can_wait_until_tomorrow"] = min_depletion_hours > 24 + + # Set deadline_utc to when stock runs out + now = datetime.now(timezone.utc) + stockout_time = now + timedelta(hours=min_depletion_hours) + urgency["deadline_utc"] = stockout_time.isoformat() + + logger.info( + "po_approval_urgency_calculated", + min_depletion_hours=min_depletion_hours, + stockout_deadline=urgency["deadline_utc"], + can_wait=urgency["can_wait_until_tomorrow"] + ) + + return urgency + def _calculate_deadline_urgency(self, deadline_str: str) -> dict: """Calculate urgency based on deadline""" try: diff --git a/services/alert_processor/app/services/enrichment_orchestrator.py b/services/alert_processor/app/services/enrichment_orchestrator.py index 7ce81182..e622b71f 100644 --- a/services/alert_processor/app/services/enrichment_orchestrator.py +++ b/services/alert_processor/app/services/enrichment_orchestrator.py @@ -8,7 +8,7 @@ from typing import Dict, Any import structlog from uuid import uuid4 -from shared.schemas.events import MinimalEvent +from shared.messaging import MinimalEvent from app.schemas.events import EnrichedEvent, I18nContent, BusinessImpact, Urgency, UserAgency, OrchestratorContext from app.enrichment.message_generator import MessageGenerator from app.enrichment.priority_scorer import PriorityScorer diff --git a/services/demo_session/app/services/cloning_strategies.py b/services/demo_session/app/services/cloning_strategies.py index 77fd633b..f355b546 100644 --- a/services/demo_session/app/services/cloning_strategies.py +++ b/services/demo_session/app/services/cloning_strategies.py @@ -130,50 +130,85 @@ class ProfessionalCloningStrategy(CloningStrategy): tasks.append(task) service_map[task] = service_def.name - # Wait for all tasks to complete - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Process results + # Process tasks as they complete for real-time progress updates service_results = {} total_records = 0 failed_services = [] required_service_failed = False + completed_count = 0 + total_count = len(tasks) - for task, result in zip(tasks, results): - service_name = service_map[task] - service_def = next(s for s in services_to_clone if s.name == service_name) + # Create a mapping from futures to service names to properly identify completed tasks + # We'll use asyncio.wait approach instead of as_completed to access the original tasks + pending = set(tasks) + completed_tasks_info = {task: service_map[task] for task in tasks} # Map tasks to service names - if isinstance(result, Exception): - logger.error( - f"Service {service_name} cloning failed with exception", - session_id=context.session_id, - error=str(result) - ) - service_results[service_name] = { - "status": "failed", - "error": str(result), - "records_cloned": 0 - } - failed_services.append(service_name) - if service_def.required: - required_service_failed = True - else: - service_results[service_name] = result - if result.get("status") == "failed": + while pending: + # Wait for at least one task to complete + done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + + # Process each completed task + for completed_task in done: + try: + # Get the result from the completed task + result = await completed_task + # Get the service name from our mapping + service_name = completed_tasks_info[completed_task] + service_def = next(s for s in services_to_clone if s.name == service_name) + + service_results[service_name] = result + completed_count += 1 + + if result.get("status") == "failed": + failed_services.append(service_name) + if service_def.required: + required_service_failed = True + else: + total_records += result.get("records_cloned", 0) + + # Track successful services for rollback + if result.get("status") == "completed": + rollback_stack.append({ + "type": "service", + "service_name": service_name, + "tenant_id": context.virtual_tenant_id, + "session_id": context.session_id + }) + + # Update Redis with granular progress after each service completes + await context.orchestrator._update_progress_in_redis(context.session_id, { + "completed_services": completed_count, + "total_services": total_count, + "progress_percentage": int((completed_count / total_count) * 100), + "services": service_results, + "total_records_cloned": total_records + }) + + logger.info( + f"Service {service_name} completed ({completed_count}/{total_count})", + session_id=context.session_id, + records_cloned=result.get("records_cloned", 0) + ) + + except Exception as e: + # Handle exceptions from the task itself + service_name = completed_tasks_info[completed_task] + service_def = next(s for s in services_to_clone if s.name == service_name) + + logger.error( + f"Service {service_name} cloning failed with exception", + session_id=context.session_id, + error=str(e) + ) + service_results[service_name] = { + "status": "failed", + "error": str(e), + "records_cloned": 0 + } failed_services.append(service_name) + completed_count += 1 if service_def.required: required_service_failed = True - else: - total_records += result.get("records_cloned", 0) - - # Track successful services for rollback - if result.get("status") == "completed": - rollback_stack.append({ - "type": "service", - "service_name": service_name, - "tenant_id": context.virtual_tenant_id, - "session_id": context.session_id - }) # Determine overall status if required_service_failed: @@ -475,7 +510,7 @@ class EnterpriseCloningStrategy(CloningStrategy): elif failed_children > 0: overall_status = "partial" else: - overall_status = "ready" + overall_status = "completed" # Changed from "ready" to match professional strategy # Calculate total records cloned (parent + all children) total_records_cloned = parent_result.get("total_records", 0) diff --git a/services/demo_session/app/services/session_manager.py b/services/demo_session/app/services/session_manager.py index 10abac30..f5163017 100644 --- a/services/demo_session/app/services/session_manager.py +++ b/services/demo_session/app/services/session_manager.py @@ -464,6 +464,14 @@ class DemoSessionManager: """Cache session status in Redis for fast status checks""" status_key = f"session:{session.session_id}:status" + # Calculate estimated remaining time based on demo tier + estimated_remaining_seconds = None + if session.cloning_started_at and not session.cloning_completed_at: + elapsed = (datetime.now(timezone.utc) - session.cloning_started_at).total_seconds() + # Professional: ~40s average, Enterprise: ~75s average + avg_duration = 75 if session.demo_account_type == 'enterprise' else 40 + estimated_remaining_seconds = max(0, int(avg_duration - elapsed)) + status_data = { "session_id": session.session_id, "status": session.status.value, @@ -471,7 +479,9 @@ class DemoSessionManager: "total_records_cloned": session.total_records_cloned, "cloning_started_at": session.cloning_started_at.isoformat() if session.cloning_started_at else None, "cloning_completed_at": session.cloning_completed_at.isoformat() if session.cloning_completed_at else None, - "expires_at": session.expires_at.isoformat() + "expires_at": session.expires_at.isoformat(), + "estimated_remaining_seconds": estimated_remaining_seconds, + "demo_account_type": session.demo_account_type } import json as json_module @@ -508,6 +518,14 @@ class DemoSessionManager: await self._cache_session_status(session) + # Calculate estimated remaining time for database fallback + estimated_remaining_seconds = None + if session.cloning_started_at and not session.cloning_completed_at: + elapsed = (datetime.now(timezone.utc) - session.cloning_started_at).total_seconds() + # Professional: ~40s average, Enterprise: ~75s average + avg_duration = 75 if session.demo_account_type == 'enterprise' else 40 + estimated_remaining_seconds = max(0, int(avg_duration - elapsed)) + return { "session_id": session.session_id, "status": session.status.value, @@ -515,7 +533,9 @@ class DemoSessionManager: "total_records_cloned": session.total_records_cloned, "cloning_started_at": session.cloning_started_at.isoformat() if session.cloning_started_at else None, "cloning_completed_at": session.cloning_completed_at.isoformat() if session.cloning_completed_at else None, - "expires_at": session.expires_at.isoformat() + "expires_at": session.expires_at.isoformat(), + "estimated_remaining_seconds": estimated_remaining_seconds, + "demo_account_type": session.demo_account_type } async def retry_failed_cloning( diff --git a/services/demo_session/scripts/seed_dashboard_comprehensive.py b/services/demo_session/scripts/seed_dashboard_comprehensive.py index 8dd71018..b8d25335 100755 --- a/services/demo_session/scripts/seed_dashboard_comprehensive.py +++ b/services/demo_session/scripts/seed_dashboard_comprehensive.py @@ -39,8 +39,7 @@ from typing import List, Dict, Any # Add project root to path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) -from shared.messaging import RabbitMQClient -from shared.schemas.alert_types import AlertTypeConstants +from shared.messaging import RabbitMQClient, AlertTypeConstants import structlog logger = structlog.get_logger() diff --git a/services/distribution/scripts/demo/seed_demo_distribution_history.py b/services/distribution/scripts/demo/seed_demo_distribution_history.py index 6a338638..9dc4af80 100644 --- a/services/distribution/scripts/demo/seed_demo_distribution_history.py +++ b/services/distribution/scripts/demo/seed_demo_distribution_history.py @@ -114,16 +114,45 @@ async def seed_distribution_history(db: AsyncSession): total_distance_km = random.uniform(75.0, 95.0) # Realistic for 3 retail outlets in region estimated_duration_minutes = random.randint(180, 240) # 3-4 hours for 3 stops - # Route sequence (order of deliveries) + # Route sequence (order of deliveries) with full GPS coordinates for map display + # Determine status based on date + is_past = delivery_date < BASE_REFERENCE_DATE + point_status = "delivered" if is_past else "pending" + route_sequence = [ - {"stop": 1, "tenant_id": str(DEMO_TENANT_CHILD_1), "location": "Madrid Centro"}, - {"stop": 2, "tenant_id": str(DEMO_TENANT_CHILD_2), "location": "Barcelona Gràcia"}, - {"stop": 3, "tenant_id": str(DEMO_TENANT_CHILD_3), "location": "Valencia Ruzafa"} + { + "tenant_id": str(DEMO_TENANT_CHILD_1), + "name": "Madrid Centro", + "address": "Calle Gran Vía 28, 28013 Madrid, Spain", + "latitude": 40.4168, + "longitude": -3.7038, + "status": point_status, + "id": str(uuid.uuid4()), + "sequence": 1 + }, + { + "tenant_id": str(DEMO_TENANT_CHILD_2), + "name": "Barcelona Gràcia", + "address": "Carrer Gran de Gràcia 15, 08012 Barcelona, Spain", + "latitude": 41.4036, + "longitude": 2.1561, + "status": point_status, + "id": str(uuid.uuid4()), + "sequence": 2 + }, + { + "tenant_id": str(DEMO_TENANT_CHILD_3), + "name": "Valencia Ruzafa", + "address": "Carrer de Sueca 51, 46006 Valencia, Spain", + "latitude": 39.4647, + "longitude": -0.3679, + "status": point_status, + "id": str(uuid.uuid4()), + "sequence": 3 + } ] - # Determine status based on whether the date is in the past or future - # Past routes are completed, today and future routes are planned - is_past = delivery_date < BASE_REFERENCE_DATE + # Route status (already determined is_past above) route_status = DeliveryRouteStatus.completed if is_past else DeliveryRouteStatus.planned route = DeliveryRoute( diff --git a/services/orchestrator/app/core/config.py b/services/orchestrator/app/core/config.py index cc60a006..43fc0893 100644 --- a/services/orchestrator/app/core/config.py +++ b/services/orchestrator/app/core/config.py @@ -40,6 +40,8 @@ class OrchestratorSettings(BaseServiceSettings): # Orchestration Settings ORCHESTRATION_ENABLED: bool = os.getenv("ORCHESTRATION_ENABLED", "true").lower() == "true" ORCHESTRATION_SCHEDULE: str = os.getenv("ORCHESTRATION_SCHEDULE", "30 5 * * *") # 5:30 AM daily (cron format) + ORCHESTRATION_HOUR: int = int(os.getenv("ORCHESTRATION_HOUR", "2")) # Hour to run daily orchestration (default: 2 AM) + ORCHESTRATION_MINUTE: int = int(os.getenv("ORCHESTRATION_MINUTE", "0")) # Minute to run (default: :00) ORCHESTRATION_TIMEOUT_SECONDS: int = int(os.getenv("ORCHESTRATION_TIMEOUT_SECONDS", "600")) # 10 minutes # Tenant Processing diff --git a/services/orchestrator/app/services/orchestrator_service.py b/services/orchestrator/app/services/orchestrator_service.py index 659c1678..1dd18f60 100644 --- a/services/orchestrator/app/services/orchestrator_service.py +++ b/services/orchestrator/app/services/orchestrator_service.py @@ -19,6 +19,7 @@ from datetime import datetime, date, timezone from decimal import Decimal from typing import List, Dict, Any, Optional import structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger # Updated imports - removed old alert system @@ -51,6 +52,9 @@ class OrchestratorSchedulerService: self.publisher = event_publisher self.config = config + # APScheduler instance for running daily orchestration + self.scheduler = None + # Service clients self.forecast_client = ForecastServiceClient(config, "orchestrator-service") self.production_client = ProductionServiceClient(config, "orchestrator-service") @@ -670,13 +674,46 @@ class OrchestratorSchedulerService: async def start(self): """Start the orchestrator scheduler service""" - logger.info("OrchestratorSchedulerService started") - # Add any initialization logic here if needed + if not settings.ORCHESTRATION_ENABLED: + logger.info("Orchestration disabled via config") + return + + # Initialize APScheduler + self.scheduler = AsyncIOScheduler() + + # Add daily orchestration job + self.scheduler.add_job( + self.run_daily_orchestration, + trigger=CronTrigger( + hour=settings.ORCHESTRATION_HOUR, + minute=settings.ORCHESTRATION_MINUTE + ), + id='daily_orchestration', + name='Daily Orchestration Workflow', + replace_existing=True, + max_instances=1, + coalesce=True + ) + + # Start the scheduler + self.scheduler.start() + + # Log next run time + next_run = self.scheduler.get_job('daily_orchestration').next_run_time + logger.info( + "OrchestratorSchedulerService started with daily job", + orchestration_hour=settings.ORCHESTRATION_HOUR, + orchestration_minute=settings.ORCHESTRATION_MINUTE, + next_run=next_run.isoformat() if next_run else None + ) async def stop(self): """Stop the orchestrator scheduler service""" - logger.info("OrchestratorSchedulerService stopped") - # Add any cleanup logic here if needed + if self.scheduler and self.scheduler.running: + self.scheduler.shutdown(wait=True) + logger.info("OrchestratorSchedulerService stopped") + else: + logger.info("OrchestratorSchedulerService already stopped") def get_circuit_breaker_stats(self) -> Dict[str, Any]: """Get circuit breaker statistics for monitoring""" diff --git a/services/procurement/app/services/delivery_tracking_service.py b/services/procurement/app/services/delivery_tracking_service.py index b895e7f2..da151fac 100644 --- a/services/procurement/app/services/delivery_tracking_service.py +++ b/services/procurement/app/services/delivery_tracking_service.py @@ -42,18 +42,41 @@ class DeliveryTrackingService: async def start(self): """Start the delivery tracking scheduler""" + # Initialize and start scheduler if not already running if not self.scheduler.running: + # Add hourly job to check deliveries + self.scheduler.add_job( + self._check_all_tenants, + trigger=CronTrigger(minute=30), # Run every hour at :30 (00:30, 01:30, 02:30, etc.) + id='hourly_delivery_check', + name='Hourly Delivery Tracking', + replace_existing=True, + max_instances=1, # Ensure no overlapping runs + coalesce=True # Combine missed runs + ) + self.scheduler.start() - logger.info( - "Delivery tracking scheduler started", - instance_id=self.instance_id - ) + + # Log next run time + next_run = self.scheduler.get_job('hourly_delivery_check').next_run_time + logger.info( + "Delivery tracking scheduler started with hourly checks", + instance_id=self.instance_id, + next_run=next_run.isoformat() if next_run else None + ) + else: + logger.info( + "Delivery tracking scheduler already running", + instance_id=self.instance_id + ) async def stop(self): """Stop the scheduler and release leader lock""" if self.scheduler.running: - self.scheduler.shutdown(wait=False) + self.scheduler.shutdown(wait=True) # Graceful shutdown logger.info("Delivery tracking scheduler stopped", instance_id=self.instance_id) + else: + logger.info("Delivery tracking scheduler already stopped", instance_id=self.instance_id) async def _check_all_tenants(self): """ diff --git a/services/production/app/api/production_batches.py b/services/production/app/api/production_batches.py index 3eb712c8..020e7b86 100644 --- a/services/production/app/api/production_batches.py +++ b/services/production/app/api/production_batches.py @@ -176,7 +176,7 @@ async def get_batch_details( from app.repositories.production_batch_repository import ProductionBatchRepository batch_repo = ProductionBatchRepository(db) - batch = await batch_repo.get(batch_id) + batch = await batch_repo.get_by_id(batch_id) if not batch or str(batch.tenant_id) != str(tenant_id): raise HTTPException(status_code=404, detail="Production batch not found") diff --git a/shared/messaging/__init__.py b/shared/messaging/__init__.py index 51ee2ac3..f2ad8dc2 100644 --- a/shared/messaging/__init__.py +++ b/shared/messaging/__init__.py @@ -9,13 +9,26 @@ from .messaging_client import ( EVENT_TYPES ) +from .schemas import ( + MinimalEvent, + EventDomain, + EventClass, + Severity, + AlertTypeConstants +) + __all__ = [ 'RabbitMQClient', - 'UnifiedEventPublisher', + 'UnifiedEventPublisher', 'ServiceMessagingManager', 'initialize_service_publisher', 'cleanup_service_publisher', 'EventMessage', 'EventType', - 'EVENT_TYPES' + 'EVENT_TYPES', + 'MinimalEvent', + 'EventDomain', + 'EventClass', + 'Severity', + 'AlertTypeConstants' ] \ No newline at end of file diff --git a/shared/schemas/events.py b/shared/messaging/schemas.py similarity index 77% rename from shared/schemas/events.py rename to shared/messaging/schemas.py index f98b6b1b..3f9f5e47 100644 --- a/shared/schemas/events.py +++ b/shared/messaging/schemas.py @@ -4,12 +4,14 @@ Minimal event schemas for services to emit events. Services send minimal event data with only event_type and metadata. All enrichment, i18n generation, and priority calculation happens in the alert_processor service. + +This is the unified messaging layer - the single source of truth for +event schemas used in the messaging system. """ from pydantic import BaseModel, Field from typing import Dict, Any, Literal, Optional from datetime import datetime -from uuid import UUID class MinimalEvent(BaseModel): @@ -116,7 +118,10 @@ class MinimalEvent(BaseModel): } +# ============================================================ # Event Domain Constants +# ============================================================ + class EventDomain: """Standard event domains""" INVENTORY = "inventory" @@ -128,7 +133,10 @@ class EventDomain: FINANCE = "finance" +# ============================================================ # Event Class Constants +# ============================================================ + class EventClass: """Event classifications""" ALERT = "alert" # Requires user decision/action @@ -136,7 +144,10 @@ class EventClass: RECOMMENDATION = "recommendation" # Optimization suggestion +# ============================================================ # Severity Levels (for routing) +# ============================================================ + class Severity: """Alert severity levels for routing""" URGENT = "urgent" # Immediate attention required @@ -144,3 +155,37 @@ class Severity: MEDIUM = "medium" # Standard priority LOW = "low" # Minor, can wait INFO = "info" # Informational only + + +# ============================================================ +# Alert Type Constants (for demo/testing purposes) +# ============================================================ + +class AlertTypeConstants: + """Standard alert type string constants""" + + # Inventory alerts + LOW_STOCK_WARNING = "low_stock_warning" + CRITICAL_STOCK_SHORTAGE = "critical_stock_shortage" + EXPIRING_SOON = "expiring_soon" + EXPIRED_STOCK = "expired_stock" + + # Production alerts + PRODUCTION_DELAY = "production_delay" + PRODUCTION_STALLED = "production_stalled" + BATCH_AT_RISK = "batch_at_risk" + PRODUCTION_BATCH_START = "production_batch_start" + + # Purchase Order alerts + PO_APPROVAL_NEEDED = "po_approval_needed" + PO_APPROVAL_ESCALATION = "po_approval_escalation" + + # Delivery lifecycle alerts + DELIVERY_SCHEDULED = "delivery_scheduled" + DELIVERY_ARRIVING_SOON = "delivery_arriving_soon" + DELIVERY_OVERDUE = "delivery_overdue" + STOCK_RECEIPT_INCOMPLETE = "stock_receipt_incomplete" + + # Forecasting alerts + DEMAND_SURGE_PREDICTED = "demand_surge_predicted" + DEMAND_DROP_PREDICTED = "demand_drop_predicted" diff --git a/shared/schemas/alert_types.py b/shared/schemas/alert_types.py deleted file mode 100644 index c512da0d..00000000 --- a/shared/schemas/alert_types.py +++ /dev/null @@ -1,276 +0,0 @@ -""" -Alert Types for Next-Generation Alert System - -Defines enriched alert types that transform passive notifications into actionable guidance. -This replaces simple severity-based alerts with context-rich, prioritized, intelligent alerts. -""" - -from enum import Enum -from typing import Dict, Any, Optional, List -from pydantic import BaseModel, Field -from datetime import datetime - - -# ============================================================ -# Alert Type Classifications -# ============================================================ - -class AlertTypeClass(str, Enum): - """High-level alert type classifications""" - ACTION_NEEDED = "action_needed" # Requires user decision - PREVENTED_ISSUE = "prevented_issue" # AI already handled, FYI - TREND_WARNING = "trend_warning" # Proactive insight - ESCALATION = "escalation" # Time-sensitive with auto-action countdown - INFORMATION = "information" # Pure informational - - -class PriorityLevel(str, Enum): - """Priority levels based on multi-factor scoring""" - CRITICAL = "critical" # 90-100: Needs decision in next 2 hours - IMPORTANT = "important" # 70-89: Needs decision today - STANDARD = "standard" # 50-69: Review when convenient - INFO = "info" # 0-49: For awareness - - -class PlacementHint(str, Enum): - """UI placement hints for where alert should appear""" - TOAST = "toast" # Immediate popup notification - ACTION_QUEUE = "action_queue" # Dashboard action queue section - DASHBOARD_INLINE = "dashboard_inline" # Embedded in relevant dashboard section - NOTIFICATION_PANEL = "notification_panel" # Bell icon notification panel - EMAIL_DIGEST = "email_digest" # End-of-day email summary - - -# ============================================================ -# Smart Action Definitions -# ============================================================ - -class SmartActionType(str, Enum): - """Types of smart actions users can take""" - APPROVE_PO = "approve_po" - REJECT_PO = "reject_po" - MODIFY_PO = "modify_po" - CALL_SUPPLIER = "call_supplier" - NAVIGATE = "navigate" - ADJUST_PRODUCTION = "adjust_production" - START_PRODUCTION_BATCH = "start_production_batch" - NOTIFY_CUSTOMER = "notify_customer" - CANCEL_AUTO_ACTION = "cancel_auto_action" - MARK_DELIVERY_RECEIVED = "mark_delivery_received" - COMPLETE_STOCK_RECEIPT = "complete_stock_receipt" - OPEN_REASONING = "open_reasoning" - SNOOZE = "snooze" - DISMISS = "dismiss" - MARK_READ = "mark_read" - - -class SmartAction(BaseModel): - """Smart action button definition""" - label: str = Field(..., description="User-facing button label") - type: SmartActionType = Field(..., description="Action type for handler routing") - variant: str = Field(default="primary", description="UI variant: primary, secondary, tertiary, danger") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Action-specific data") - disabled: bool = Field(default=False, description="Whether action is disabled") - disabled_reason: Optional[str] = Field(None, description="Reason why action is disabled") - estimated_time_minutes: Optional[int] = Field(None, description="Estimated time to complete action") - consequence: Optional[str] = Field(None, description="What happens if this action is taken") - - -# ============================================================ -# Context & Enrichment Models -# ============================================================ - -class OrchestratorContext(BaseModel): - """Context from Daily Orchestrator about recent actions""" - already_addressed: bool = Field(..., description="Has AI already addressed this issue?") - action_type: Optional[str] = Field(None, description="Type of action taken: PO, batch, adjustment") - action_id: Optional[str] = Field(None, description="ID of the PO/batch created") - action_status: Optional[str] = Field(None, description="Status: created, pending_approval, completed") - delivery_date: Optional[datetime] = Field(None, description="When will solution arrive") - reasoning: Optional[Dict[str, Any]] = Field(None, description="Structured reasoning data") - estimated_resolution_time: Optional[datetime] = Field(None, description="When issue will be resolved") - estimated_savings_eur: Optional[float] = Field(None, description="Estimated savings from preventing this issue") - - -class BusinessImpact(BaseModel): - """Business impact assessment""" - financial_impact_eur: Optional[float] = Field(None, description="Estimated € impact") - affected_orders: Optional[int] = Field(None, description="Number of orders affected") - affected_customers: Optional[List[str]] = Field(None, description="Customer names affected") - production_batches_at_risk: Optional[List[str]] = Field(None, description="Batch IDs at risk") - stockout_risk_hours: Optional[float] = Field(None, description="Hours until stockout") - waste_risk_kg: Optional[float] = Field(None, description="Kg of waste risk") - customer_satisfaction_impact: Optional[str] = Field(None, description="Impact level: high, medium, low") - - -class UrgencyContext(BaseModel): - """Urgency and timing context""" - deadline: Optional[datetime] = Field(None, description="Hard deadline for decision") - time_until_consequence_hours: Optional[float] = Field(None, description="Hours until consequence occurs") - can_wait_until_tomorrow: bool = Field(default=True, description="Can this wait until tomorrow?") - peak_hour_relevant: bool = Field(default=False, description="Is this relevant during peak hours?") - auto_action_countdown_seconds: Optional[int] = Field(None, description="Seconds until auto-action triggers") - - -class UserAgency(BaseModel): - """User's ability to act on this alert""" - can_user_fix: bool = Field(..., description="Can the user actually fix this?") - requires_external_party: bool = Field(default=False, description="Requires supplier/customer action?") - external_party_name: Optional[str] = Field(None, description="Name of external party") - external_party_contact: Optional[str] = Field(None, description="Phone/email of external party") - blockers: Optional[List[str]] = Field(None, description="Things blocking user from acting") - suggested_workaround: Optional[str] = Field(None, description="Alternative solution if blocked") - - -class TrendContext(BaseModel): - """Trend analysis context""" - metric_name: str = Field(..., description="Name of metric trending") - current_value: float = Field(..., description="Current value") - baseline_value: float = Field(..., description="Baseline/expected value") - change_percentage: float = Field(..., description="Percentage change") - direction: str = Field(..., description="Direction: increasing, decreasing") - significance: str = Field(..., description="Significance: high, medium, low") - period_days: int = Field(..., description="Number of days in trend period") - possible_causes: Optional[List[str]] = Field(None, description="Potential root causes") - - -# ============================================================ -# Enriched Alert Model -# ============================================================ - -class EnrichedAlert(BaseModel): - """ - Next-generation enriched alert with full context and guidance. - This is what gets sent to the frontend after intelligence processing. - """ - - # Original Alert Data - id: str = Field(..., description="Alert UUID") - tenant_id: str = Field(..., description="Tenant UUID") - service: str = Field(..., description="Originating service") - alert_type: str = Field(..., description="Specific alert type code") - title: str = Field(..., description="User-facing title") - message: str = Field(..., description="Detailed message") - - # Classification - type_class: AlertTypeClass = Field(..., description="High-level classification") - priority_level: PriorityLevel = Field(..., description="Priority level") - priority_score: int = Field(..., description="Numeric priority score 0-100") - - # Context Enrichment - orchestrator_context: Optional[OrchestratorContext] = Field(None, description="AI system context") - business_impact: Optional[BusinessImpact] = Field(None, description="Business impact assessment") - urgency_context: Optional[UrgencyContext] = Field(None, description="Urgency and timing") - user_agency: Optional[UserAgency] = Field(None, description="User's ability to act") - trend_context: Optional[TrendContext] = Field(None, description="Trend analysis (if trend warning)") - - # AI Reasoning - ai_reasoning_i18n: Optional[Dict[str, Any]] = Field(None, description="i18n-ready AI reasoning with key and params") - reasoning_data: Optional[Dict[str, Any]] = Field(None, description="Structured reasoning from orchestrator") - confidence_score: Optional[float] = Field(None, description="AI confidence 0-1") - - # Actions - actions: List[SmartAction] = Field(default_factory=list, description="Smart action buttons") - primary_action: Optional[SmartAction] = Field(None, description="Primary recommended action") - - # UI Placement - placement: List[PlacementHint] = Field(default_factory=list, description="Where to show this alert") - - # Grouping - group_id: Optional[str] = Field(None, description="Group ID if part of grouped alerts") - is_group_summary: bool = Field(default=False, description="Is this a group summary?") - grouped_alert_count: Optional[int] = Field(None, description="Number of alerts in group") - grouped_alert_ids: Optional[List[str]] = Field(None, description="IDs of grouped alerts") - - # Metadata - created_at: datetime = Field(..., description="When alert was created") - enriched_at: datetime = Field(..., description="When alert was enriched") - alert_metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata") - - # Status - status: str = Field(default="active", description="Status: active, resolved, acknowledged, snoozed") - - -# ============================================================ -# Raw Alert Input Model -# ============================================================ - -class RawAlert(BaseModel): - """ - Raw alert from originating services (inventory, production, etc.) - This is what services send before enrichment. - """ - tenant_id: str - alert_type: str - title: str - message: str - service: str - actions: Optional[List[str]] = None # Simple action labels - alert_metadata: Dict[str, Any] = Field(default_factory=dict) - item_type: str = Field(default="alert") # alert or recommendation - - -# ============================================================ -# Alert Group Model -# ============================================================ - -class AlertGroup(BaseModel): - """Grouped alerts for better UX""" - group_id: str = Field(..., description="Group UUID") - tenant_id: str = Field(..., description="Tenant UUID") - group_type: str = Field(..., description="Type of grouping: supplier, service, type") - title: str = Field(..., description="Group title") - summary: str = Field(..., description="Group summary message") - alert_count: int = Field(..., description="Number of alerts in group") - alert_ids: List[str] = Field(..., description="Alert UUIDs in group") - highest_priority_score: int = Field(..., description="Highest priority in group") - created_at: datetime = Field(..., description="When group was created") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Group metadata") - - -# ============================================================ -# Priority Scoring Components -# ============================================================ - -class PriorityScoreComponents(BaseModel): - """Breakdown of priority score calculation""" - business_impact_score: float = Field(..., description="Business impact component 0-100") - urgency_score: float = Field(..., description="Urgency component 0-100") - user_agency_score: float = Field(..., description="User agency component 0-100") - confidence_score: float = Field(..., description="Confidence component 0-100") - final_score: int = Field(..., description="Final weighted score 0-100") - weights: Dict[str, float] = Field(..., description="Weights used in calculation") - - -# ============================================================ -# Standard Alert Type Constants -# ============================================================ - -class AlertTypeConstants: - """Standard alert type string constants""" - - # Inventory alerts - LOW_STOCK_WARNING = "low_stock_warning" - CRITICAL_STOCK_SHORTAGE = "critical_stock_shortage" - EXPIRING_SOON = "expiring_soon" - EXPIRED_STOCK = "expired_stock" - - # Production alerts - PRODUCTION_DELAY = "production_delay" - PRODUCTION_STALLED = "production_stalled" - BATCH_AT_RISK = "batch_at_risk" - PRODUCTION_BATCH_START = "production_batch_start" - - # Purchase Order alerts - PO_APPROVAL_NEEDED = "po_approval_needed" - PO_APPROVAL_ESCALATION = "po_approval_escalation" - - # Delivery lifecycle alerts (NEW) - DELIVERY_SCHEDULED = "delivery_scheduled" - DELIVERY_ARRIVING_SOON = "delivery_arriving_soon" - DELIVERY_OVERDUE = "delivery_overdue" - STOCK_RECEIPT_INCOMPLETE = "stock_receipt_incomplete" - - # Forecasting alerts - DEMAND_SURGE_PREDICTED = "demand_surge_predicted" - DEMAND_DROP_PREDICTED = "demand_drop_predicted" diff --git a/shared/schemas/event_classification.py b/shared/schemas/event_classification.py deleted file mode 100644 index 092f0e40..00000000 --- a/shared/schemas/event_classification.py +++ /dev/null @@ -1,343 +0,0 @@ -""" -Event Classification Schema - -This module defines the three-tier event model that separates: -- ALERTS: Actionable events requiring user decision -- NOTIFICATIONS: Informational state changes (FYI only) -- RECOMMENDATIONS: Advisory suggestions from AI - -This replaces the old conflated "alert" system with semantic clarity. -""" - -from enum import Enum -from typing import Any, Dict, List, Optional -from datetime import datetime -from pydantic import BaseModel, Field - - -class EventClass(str, Enum): - """ - Top-level event classification. - - - ALERT: Actionable, requires user decision, has smart actions - - NOTIFICATION: Informational state change, no action needed - - RECOMMENDATION: Advisory suggestion, optional action - """ - ALERT = "alert" - NOTIFICATION = "notification" - RECOMMENDATION = "recommendation" - - -class EventDomain(str, Enum): - """ - Business domain classification for events. - Enables domain-specific dashboards and selective subscription. - """ - INVENTORY = "inventory" - PRODUCTION = "production" - SUPPLY_CHAIN = "supply_chain" - DEMAND = "demand" - OPERATIONS = "operations" - - -class PriorityLevel(str, Enum): - """Priority levels for alerts and recommendations.""" - CRITICAL = "critical" # 90-100: Immediate action required - IMPORTANT = "important" # 70-89: Action needed soon - STANDARD = "standard" # 50-69: Normal priority - INFO = "info" # 0-49: Low priority, informational - - -class AlertTypeClass(str, Enum): - """ - Alert-specific classification (only applies to EventClass.ALERT). - """ - ACTION_NEEDED = "action_needed" # User must decide - PREVENTED_ISSUE = "prevented_issue" # AI already handled, FYI - TREND_WARNING = "trend_warning" # Pattern detected - ESCALATION = "escalation" # Time-sensitive with auto-action countdown - INFORMATION = "information" # Pure informational alert - - -class NotificationType(str, Enum): - """ - Notification-specific types for state changes. - """ - STATE_CHANGE = "state_change" # Entity state transition - COMPLETION = "completion" # Process/task completed - ARRIVAL = "arrival" # Entity arrived/received - DEPARTURE = "departure" # Entity left/shipped - UPDATE = "update" # General update - SYSTEM_EVENT = "system_event" # System operation - - -class RecommendationType(str, Enum): - """ - Recommendation-specific types. - """ - OPTIMIZATION = "optimization" # Efficiency improvement - COST_REDUCTION = "cost_reduction" # Save money - RISK_MITIGATION = "risk_mitigation" # Prevent future issues - TREND_INSIGHT = "trend_insight" # Pattern analysis - BEST_PRACTICE = "best_practice" # Suggested approach - - -class RawEvent(BaseModel): - """ - Base event emitted by domain services. - - This is the unified schema replacing the old RawAlert. - All domain services emit RawEvents which are then conditionally enriched. - """ - tenant_id: str = Field(..., description="Tenant identifier") - - # Event classification - event_class: EventClass = Field(..., description="Alert, Notification, or Recommendation") - event_domain: EventDomain = Field(..., description="Business domain (inventory, production, etc.)") - event_type: str = Field(..., description="Specific event type (e.g., 'critical_stock_shortage')") - - # Core content - title: str = Field(..., description="Event title") - message: str = Field(..., description="Event message") - - # Source - service: str = Field(..., description="Originating service name") - - # Actions (optional, mainly for alerts) - actions: Optional[List[str]] = Field(default=None, description="Available action types") - - # Metadata (domain-specific data) - event_metadata: Dict[str, Any] = Field(default_factory=dict, description="Domain-specific metadata") - - # Timestamp - timestamp: datetime = Field(default_factory=datetime.utcnow, description="Event creation time") - - # Deduplication (optional) - deduplication_key: Optional[str] = Field(default=None, description="Key for deduplication") - - class Config: - use_enum_values = True - - -class EnrichedAlert(BaseModel): - """ - Fully enriched alert with priority scoring, smart actions, and context. - Only used for EventClass.ALERT. - """ - # From RawEvent - id: str - tenant_id: str - event_domain: EventDomain - event_type: str - title: str - message: str - service: str - timestamp: datetime - - # Alert-specific - type_class: AlertTypeClass - status: str # active, acknowledged, resolved, dismissed - - # Priority - priority_score: int = Field(..., ge=0, le=100, description="0-100 priority score") - priority_level: PriorityLevel - - # Enrichment context - orchestrator_context: Optional[Dict[str, Any]] = Field(default=None) - business_impact: Optional[Dict[str, Any]] = Field(default=None) - urgency_context: Optional[Dict[str, Any]] = Field(default=None) - user_agency: Optional[Dict[str, Any]] = Field(default=None) - - # Smart actions - smart_actions: Optional[List[Dict[str, Any]]] = Field(default=None) - - # AI reasoning - ai_reasoning_summary: Optional[str] = Field(default=None) - confidence_score: Optional[float] = Field(default=None, ge=0.0, le=1.0) - - # Timing - timing_decision: Optional[str] = Field(default=None) - scheduled_send_time: Optional[datetime] = Field(default=None) - placement: Optional[List[str]] = Field(default=None) - - # Metadata - alert_metadata: Dict[str, Any] = Field(default_factory=dict) - - class Config: - use_enum_values = True - - -class Notification(BaseModel): - """ - Lightweight notification for state changes. - Only used for EventClass.NOTIFICATION. - """ - # From RawEvent - id: str - tenant_id: str - event_domain: EventDomain - event_type: str - notification_type: NotificationType - title: str - message: str - service: str - timestamp: datetime - - # Lightweight context - entity_type: Optional[str] = Field(default=None, description="Type of entity (batch, delivery, etc.)") - entity_id: Optional[str] = Field(default=None, description="ID of entity") - old_state: Optional[str] = Field(default=None, description="Previous state") - new_state: Optional[str] = Field(default=None, description="New state") - - # Display metadata - notification_metadata: Dict[str, Any] = Field(default_factory=dict) - - # Placement (lightweight, typically just toast + panel) - placement: List[str] = Field(default_factory=lambda: ["notification_panel"]) - - # TTL tracking - expires_at: Optional[datetime] = Field(default=None, description="Auto-delete after this time") - - class Config: - use_enum_values = True - - -class Recommendation(BaseModel): - """ - AI-generated recommendation with moderate enrichment. - Only used for EventClass.RECOMMENDATION. - """ - # From RawEvent - id: str - tenant_id: str - event_domain: EventDomain - event_type: str - recommendation_type: RecommendationType - title: str - message: str - service: str - timestamp: datetime - - # Recommendation-specific - priority_level: PriorityLevel = Field(default=PriorityLevel.INFO) - - # Context (lighter than alerts, no orchestrator queries) - estimated_impact: Optional[Dict[str, Any]] = Field(default=None, description="Estimated benefit") - suggested_actions: Optional[List[Dict[str, Any]]] = Field(default=None) - - # AI reasoning - ai_reasoning_summary: Optional[str] = Field(default=None) - confidence_score: Optional[float] = Field(default=None, ge=0.0, le=1.0) - - # Dismissal tracking - dismissed_at: Optional[datetime] = Field(default=None) - dismissed_by: Optional[str] = Field(default=None) - - # Metadata - recommendation_metadata: Dict[str, Any] = Field(default_factory=dict) - - class Config: - use_enum_values = True - - -# Event type mappings for easy classification -EVENT_TYPE_TO_CLASS_MAP = { - # Alerts (actionable) - "critical_stock_shortage": (EventClass.ALERT, EventDomain.INVENTORY), - "production_delay": (EventClass.ALERT, EventDomain.PRODUCTION), - "equipment_failure": (EventClass.ALERT, EventDomain.PRODUCTION), - "po_approval_needed": (EventClass.ALERT, EventDomain.SUPPLY_CHAIN), - "delivery_overdue": (EventClass.ALERT, EventDomain.SUPPLY_CHAIN), - "temperature_breach": (EventClass.ALERT, EventDomain.INVENTORY), - "expired_products": (EventClass.ALERT, EventDomain.INVENTORY), - "low_stock_warning": (EventClass.ALERT, EventDomain.INVENTORY), - "production_ingredient_shortage": (EventClass.ALERT, EventDomain.INVENTORY), - "order_overload": (EventClass.ALERT, EventDomain.PRODUCTION), - - # Notifications (informational) - "stock_received": (EventClass.NOTIFICATION, EventDomain.INVENTORY), - "stock_movement": (EventClass.NOTIFICATION, EventDomain.INVENTORY), - "batch_state_changed": (EventClass.NOTIFICATION, EventDomain.PRODUCTION), - "batch_completed": (EventClass.NOTIFICATION, EventDomain.PRODUCTION), - "orchestration_run_started": (EventClass.NOTIFICATION, EventDomain.OPERATIONS), - "orchestration_run_completed": (EventClass.NOTIFICATION, EventDomain.OPERATIONS), - "po_approved": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN), - "po_sent_to_supplier": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN), - "delivery_scheduled": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN), - "delivery_arriving_soon": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN), - "delivery_received": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN), - - # Recommendations (advisory) - "demand_surge_predicted": (EventClass.RECOMMENDATION, EventDomain.DEMAND), - "weather_impact_forecast": (EventClass.RECOMMENDATION, EventDomain.DEMAND), - "holiday_preparation": (EventClass.RECOMMENDATION, EventDomain.DEMAND), - "inventory_optimization_opportunity": (EventClass.RECOMMENDATION, EventDomain.INVENTORY), - "cost_reduction_suggestion": (EventClass.RECOMMENDATION, EventDomain.SUPPLY_CHAIN), - "efficiency_improvement": (EventClass.RECOMMENDATION, EventDomain.PRODUCTION), -} - - -def get_event_classification(event_type: str) -> tuple[EventClass, EventDomain]: - """ - Get the event_class and event_domain for a given event_type. - - Args: - event_type: The specific event type string - - Returns: - Tuple of (EventClass, EventDomain) - - Raises: - ValueError: If event_type is not recognized - """ - if event_type in EVENT_TYPE_TO_CLASS_MAP: - return EVENT_TYPE_TO_CLASS_MAP[event_type] - - # Default: treat unknown types as notifications in operations domain - return (EventClass.NOTIFICATION, EventDomain.OPERATIONS) - - -def get_redis_channel(tenant_id: str, event_domain: EventDomain, event_class: EventClass) -> str: - """ - Get the Redis pub/sub channel name for an event. - - Pattern: tenant:{tenant_id}:{domain}.{class} - Examples: - - tenant:uuid:inventory.alerts - - tenant:uuid:production.notifications - - tenant:uuid:recommendations (recommendations not domain-specific) - - Args: - tenant_id: Tenant identifier - event_domain: Event domain - event_class: Event class - - Returns: - Redis channel name - """ - if event_class == EventClass.RECOMMENDATION: - # Recommendations go to a tenant-wide channel - return f"tenant:{tenant_id}:recommendations" - - return f"tenant:{tenant_id}:{event_domain.value}.{event_class.value}s" - - -def get_rabbitmq_routing_key(event_class: EventClass, event_domain: EventDomain, severity: str) -> str: - """ - Get the RabbitMQ routing key for an event. - - Pattern: {event_class}.{event_domain}.{severity} - Examples: - - alert.inventory.urgent - - notification.production.info - - recommendation.demand.medium - - Args: - event_class: Event class - event_domain: Event domain - severity: Severity level (urgent, high, medium, low) - - Returns: - RabbitMQ routing key - """ - return f"{event_class.value}.{event_domain.value}.{severity}"