Fix and UI imporvements

This commit is contained in:
Urtzi Alfaro
2025-12-09 10:21:41 +01:00
parent 667e6e0404
commit 508f4569b9
22 changed files with 833 additions and 953 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: `<div style="background-color: ${color}; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 3px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3); font-size: 14px; font-weight: bold; color: white;">${number}</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
};
const DistributionMap: React.FC<DistributionMapProps> = ({
routes = [],
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
}) => {
const { t } = useTranslation('dashboard');
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
const [showAllRoutes, setShowAllRoutes] = useState(true);
const renderMapVisualization = () => {
if (!routes || routes.length === 0) {
@@ -87,9 +108,12 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
);
}
// 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<DistributionMapProps> = ({
);
}
// 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 (
<div className="relative h-64 lg:h-96 rounded-xl overflow-hidden border-2" style={{
background: 'linear-gradient(135deg, var(--color-info-50) 0%, var(--color-primary-50) 50%, var(--color-secondary-50) 100%)',
borderColor: 'var(--border-primary)'
}}>
{/* Bakery-themed pattern overlay */}
<div className="absolute inset-0 opacity-5 bg-pattern" />
<div className="relative h-96 rounded-xl overflow-hidden border-2" style={{ borderColor: 'var(--border-primary)' }}>
<MapContainer
center={[centerLat, centerLng]}
zoom={6}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Central Info Display */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center mb-3 mx-auto shadow-lg"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<MapIcon className="w-10 h-10" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="text-2xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.distribution_map')}
</div>
<div className="text-sm font-medium px-4 py-2 rounded-full inline-block" style={{
backgroundColor: 'var(--color-info-100)',
color: 'var(--color-info-900)'
}}>
{activeRoutes.length} {t('enterprise.active_routes')}
</div>
</div>
</div>
{/* Render each route */}
{activeRoutes.map((route, routeIdx) => {
const points = route.route_sequence || [];
const routeColor = route.status === 'in_progress' ? '#3b82f6' : '#f59e0b';
{/* Glassmorphism Route Info Cards */}
<div className="absolute top-4 left-4 right-4 flex flex-wrap gap-2">
{activeRoutes.slice(0, 3).map((route, index) => (
<div
key={route.id}
className="glass-effect p-3 rounded-lg shadow-md backdrop-blur-sm max-w-xs"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid var(--border-primary)'
}}
>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
backgroundColor: 'var(--color-info-100)',
// Create polyline coordinates
const polylinePositions: [number, number][] = points.map(p => [p.latitude, p.longitude]);
return (
<React.Fragment key={route.id}>
{/* Route polyline */}
<Polyline
positions={polylinePositions}
pathOptions={{
color: routeColor,
weight: 4,
opacity: 0.7
}}
>
<Route className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.route')} {route.route_number}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h
</div>
</div>
</div>
</div>
))}
{activeRoutes.length > 3 && (
<div
className="glass-effect p-3 rounded-lg shadow-md backdrop-blur-sm"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid var(--border-primary)'
}}
>
<div className="text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
+{activeRoutes.length - 3} more
</div>
</div>
)}
</div>
/>
{/* 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 (
<Marker
key={`${route.id}-${idx}`}
position={[point.latitude, point.longitude]}
icon={createRouteMarker(markerColor, point.sequence)}
>
<Popup>
<div className="min-w-[200px]">
<div className="font-semibold text-base mb-1">{point.name}</div>
<div className="text-sm text-gray-600 mb-2">{point.address}</div>
<div className="text-xs text-gray-500">
<div>Route: {route.route_number}</div>
<div>Stop {point.sequence} of {points.length}</div>
<div className="capitalize mt-1">Status: {point.status}</div>
</div>
</div>
</Popup>
</Marker>
);
})}
</React.Fragment>
);
})}
</MapContainer>
{/* Status Legend Overlay */}
<div
className="absolute bottom-4 right-4 glass-effect p-4 rounded-lg shadow-lg backdrop-blur-sm space-y-2"
className="absolute bottom-4 right-4 glass-effect p-4 rounded-lg shadow-lg backdrop-blur-sm space-y-2 z-[1000]"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid var(--border-primary)'
}}
>
<div className="text-xs font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{activeRoutes.length} {t('enterprise.active_routes')}
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-warning)' }}></div>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#22c55e' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.pending')}: {shipments.pending}
{t('enterprise.delivered')}: {shipments.delivered}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-info)' }}></div>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#3b82f6' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.in_transit')}: {shipments.in_transit}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-success)' }}></div>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#f59e0b' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.delivered')}: {shipments.delivered}
{t('enterprise.pending')}: {shipments.pending}
</span>
</div>
{shipments.failed > 0 && (
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-error)' }}></div>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#ef4444' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.failed')}: {shipments.failed}
</span>
@@ -437,9 +456,9 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
</div>
{/* Timeline of Stops */}
{route.route_points && route.route_points.length > 0 && (
{route.route_sequence && route.route_sequence.length > 0 && (
<div className="ml-6 border-l-2 pl-6 space-y-3" style={{ borderColor: 'var(--border-secondary)' }}>
{route.route_points.map((point, idx) => {
{route.route_sequence.map((point, idx) => {
const getPointStatusColor = (status: string) => {
switch (status) {
case 'delivered':

View File

@@ -189,6 +189,7 @@ export function useEventNotifications(config: UseNotificationsConfig = {}): UseE
notifications,
recentNotifications,
isLoading: isLoading || !isConnected,
isConnected, // Added this missing return property
clearNotifications,
};
}

View File

@@ -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 */}
<div className="flex flex-col gap-6">
<div className="space-y-6">
{/* Tabs Navigation */}
<div className="flex border-b border-gray-200">
<button
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'overview'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab('overview')}
>
Vista General
</button>
<button
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'routes'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab('routes')}
>
Listado de Rutas
</button>
<button
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'shipments'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab('shipments')}
>
Listado de Envíos
</button>
</div>
<Tabs
value={activeTab}
onValueChange={(value: 'overview' | 'routes' | 'shipments') => setActiveTab(value)}
className="w-full"
>
<TabsList className="flex w-full">
<TabsTrigger value="overview" className="flex-1">
{t('operations:distribution.tabs.overview', 'Vista General')}
</TabsTrigger>
<TabsTrigger value="routes" className="flex-1">
{t('operations:distribution.tabs.routes', 'Listado de Rutas')}
</TabsTrigger>
<TabsTrigger value="shipments" className="flex-1">
{t('operations:distribution.tabs.shipments', 'Listado de Envíos')}
</TabsTrigger>
</TabsList>
{/* Content based on Active Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Content based on Active Tab */}
<TabsContent value="overview" className="space-y-6 mt-6">
{/* Map Section */}
<Card className="overflow-hidden border-none shadow-lg">
<CardHeader className="bg-white border-b sticky top-0 z-10">
<Card className="overflow-hidden">
<CardHeader className="sticky top-0 z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-blue-100 rounded-lg">
<MapPin className="w-5 h-5 text-blue-600" />
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--color-info-50)] dark:bg-[var(--color-info-900)]">
<MapPin className="w-5 h-5 text-[var(--color-info)] dark:text-[var(--color-info-300)]" />
</div>
<div>
<CardTitle>{t('operations:map.title', 'Mapa de Distribución')}</CardTitle>
<p className="text-sm text-gray-500">Visualización en tiempo real de la flota</p>
<CardTitle className="text-lg">
{t('operations:map.title', 'Mapa de Distribución')}
</CardTitle>
<p className="text-sm text-[var(--text-secondary)]">
{t('operations:map.description', 'Visualización en tiempo real de la flota')}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1">
<Badge variant="outline" className="flex items-center gap-1 border-[var(--border-primary)]">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
En Vivo
{t('operations:map.live', 'En Vivo')}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="p-4 bg-slate-50">
<DistributionMap
routes={distributionData?.route_sequences || []}
shipments={shipmentStatus}
/>
<div className="p-4 bg-[var(--bg-secondary)]">
<div className="aspect-video rounded-lg overflow-hidden bg-[var(--bg-tertiary)] flex items-center justify-center">
<DistributionMap
routes={distributionData?.route_sequences || []}
shipments={shipmentStatus}
/>
</div>
</div>
</CardContent>
</Card>
@@ -189,7 +181,9 @@ const DistributionPage: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Rutas en Progreso</CardTitle>
<CardTitle className="text-lg">
{t('operations:distribution.active_routes', 'Rutas en Progreso')}
</CardTitle>
</CardHeader>
<CardContent>
{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) => (
<div key={route.id} className="flex items-center justify-between p-3 bg-white border rounded-lg shadow-sm">
<div
key={route.id}
className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] border rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-full">
<Truck className="w-4 h-4 text-blue-600" />
<div className="p-2 rounded-full bg-[var(--color-info-50)] dark:bg-[var(--color-info-900)]">
<Truck
className="w-4 h-4 text-[var(--color-info)] dark:text-[var(--color-info-300)]"
/>
</div>
<div>
<p className="font-medium text-sm text-gray-900">Ruta {route.route_number}</p>
<p className="text-xs text-gray-500">{route.formatted_driver_name || 'Sin conductor asignado'}</p>
<p className="font-medium text-[var(--text-primary)]">
{t('operations:distribution.route_prefix', 'Ruta')} {route.route_number}
</p>
<p className="text-sm text-[var(--text-secondary)]">
{route.formatted_driver_name || t('operations:distribution.no_driver', 'Sin conductor asignado')}
</p>
</div>
</div>
<Badge variant="info">En Ruta</Badge>
<Badge variant="info">
{t('operations:distribution.status.in_progress', 'En Ruta')}
</Badge>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
No hay rutas en progreso actualmente.
<div className="text-center py-8 text-[var(--text-secondary)]">
{t('operations:distribution.no_active_routes', 'No hay rutas en progreso actualmente.')}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t('operations:distribution.pending_deliveries', 'Entregas Pendientes')}
</CardTitle>
</CardHeader>
<CardContent>
{distributionData?.status_counts?.pending > 0 ? (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] border rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-[var(--color-warning-50)] dark:bg-[var(--color-warning-900)]">
<Package
className="w-4 h-4 text-[var(--color-warning)] dark:text-[var(--color-warning-300)]"
/>
</div>
<div>
<p className="font-medium text-[var(--text-primary)]">
{t('operations:distribution.pending_count', 'Entregas Pendientes')}
</p>
<p className="text-sm text-[var(--text-secondary)]">
{t('operations:distribution.pending_desc', 'Aún por distribuir')}
</p>
</div>
</div>
<Badge variant="warning">
{distributionData.status_counts.pending}
</Badge>
</div>
</div>
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
{t('operations:distribution.no_pending', 'No hay entregas pendientes.')}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)}
</TabsContent>
{activeTab === 'routes' && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Listado de Rutas</CardTitle>
<div className="flex gap-2">
<Input
placeholder="Buscar rutas..."
leftIcon={<Search className="w-4 h-4 text-gray-400" />}
className="w-64"
/>
<Button variant="outline" size="sm" leftIcon={<Filter className="w-4 h-4" />}>Filtros</Button>
<TabsContent value="routes" className="space-y-6 mt-6">
<Card>
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<CardTitle className="text-lg">
{t('operations:distribution.routes_list', 'Listado de Rutas')}
</CardTitle>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{t('operations:distribution.routes_desc', 'Gestión y seguimiento de rutas de distribución')}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Input
placeholder={t('operations:distribution.search_routes', 'Buscar rutas...')}
leftIcon={<Search className="w-4 h-4 text-[var(--text-tertiary)]" />}
className="w-full sm:w-64"
/>
<Button variant="outline" size="sm" leftIcon={<Filter className="w-4 h-4" />}>
{t('operations:actions.filters', 'Filtros')}
</Button>
</div>
</div>
</div>
</CardHeader>
<CardContent>
{(distributionData?.route_sequences?.length || 0) > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th className="px-4 py-3">Ruta</th>
<th className="px-4 py-3">Estado</th>
<th className="px-4 py-3">Distancia</th>
<th className="px-4 py-3">Duración Est.</th>
<th className="px-4 py-3">Paradas</th>
<th className="px-4 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
{distributionData.route_sequences.map((route: any) => (
<tr key={route.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{route.route_number}</td>
<td className="px-4 py-3">
<Badge variant={
route.status === 'completed' ? 'success' :
route.status === 'in_progress' ? 'info' :
route.status === 'pending' ? 'warning' : 'default'
}>
{route.status}
</Badge>
</td>
<td className="px-4 py-3">{route.total_distance_km?.toFixed(1) || '-'} km</td>
<td className="px-4 py-3">{route.estimated_duration_minutes || '-'} min</td>
<td className="px-4 py-3">{route.route_points?.length || 0}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" leftIcon={<MoreVertical className="w-4 h-4" />} />
</td>
</CardHeader>
<CardContent>
{(distributionData?.route_sequences?.length || 0) > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-[var(--text-secondary)] uppercase bg-[var(--bg-secondary)] border-b">
<tr>
<th className="px-4 py-3">{t('operations:distribution.table.route', 'Ruta')}</th>
<th className="px-4 py-3">{t('operations:distribution.table.status', 'Estado')}</th>
<th className="px-4 py-3">{t('operations:distribution.table.distance', 'Distancia')}</th>
<th className="px-4 py-3">{t('operations:distribution.table.duration', 'Duración Est.')}</th>
<th className="px-4 py-3">{t('operations:distribution.table.stops', 'Paradas')}</th>
<th className="px-4 py-3 text-right">{t('operations:distribution.table.actions', 'Acciones')}</th>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed">
<p className="text-gray-500">No se encontraron rutas para esta fecha.</p>
</div>
)}
</CardContent>
</Card>
)}
</thead>
<tbody className="divide-y divide-[var(--border-primary)]">
{distributionData.route_sequences.map((route: any) => (
<tr key={route.id} className="hover:bg-[var(--bg-secondary)] transition-colors">
<td className="px-4 py-3 font-medium text-[var(--text-primary)]">
{t('operations:distribution.route_prefix', 'Ruta')} {route.route_number}
</td>
<td className="px-4 py-3">
<Badge variant={
route.status === 'completed' ? 'success' :
route.status === 'in_progress' ? 'info' :
route.status === 'pending' ? 'warning' : 'default'
}>
{route.status}
</Badge>
</td>
<td className="px-4 py-3">
{route.total_distance_km?.toFixed(1) || '-'} km
</td>
<td className="px-4 py-3">
{route.estimated_duration_minutes || '-'} min
</td>
<td className="px-4 py-3">
{route.route_points?.length || 0}
</td>
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
size="sm"
icon={MoreVertical}
aria-label={t('operations:actions.more_options', 'Más opciones')}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 border-2 border-dashed border-[var(--border-primary)] rounded-lg">
<p className="text-[var(--text-secondary)]">
{t('operations:distribution.no_routes_found', 'No se encontraron rutas para esta fecha.')}
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Similar structure for Shipments tab, simplified for now */}
{activeTab === 'shipments' && (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed">
<Package className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<h3 className="text-lg font-medium text-gray-900">Gestión de Envíos</h3>
<p className="text-gray-500">Funcionalidad de listado detallado de envíos próximamente.</p>
</div>
)}
{/* Similar structure for Shipments tab, simplified for now */}
<TabsContent value="shipments" className="space-y-6 mt-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t('operations:distribution.shipments_list', 'Gestión de Envíos')}
</CardTitle>
<p className="text-sm text-[var(--text-secondary)]">
{t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')}
</p>
</CardHeader>
<CardContent>
<div className="text-center py-12 border-2 border-dashed border-[var(--border-primary)] rounded-lg">
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
{t('operations:distribution.shipments_title', 'Gestión de Envíos')}
</h3>
<p className="text-[var(--text-secondary)]">
{t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);

View File

@@ -58,6 +58,7 @@ const DemoPage = () => {
const [creationError, setCreationError] = useState('');
const [estimatedProgress, setEstimatedProgress] = useState(0);
const [progressStartTime, setProgressStartTime] = useState<number | null>(null);
const [estimatedRemainingSeconds, setEstimatedRemainingSeconds] = useState<number | null>(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 = () => {
<Modal
isOpen={creatingTier !== null}
onClose={() => { }}
size="md"
size="lg"
>
<ModalHeader
title="Configurando Tu Demo"
title={
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
<span>Configurando Tu Demo</span>
</div>
}
showCloseButton={false}
/>
<ModalBody padding="lg">
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span>Progreso total</span>
<span>{cloneProgress.overall}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${cloneProgress.overall}%` }}
></div>
</div>
<div className="text-center text-sm text-[var(--text-secondary)] mt-4">
{getLoadingMessage(creatingTier, cloneProgress.overall)}
</div>
{creatingTier === 'enterprise' && (
<div className="space-y-3 mt-4">
<div className="flex justify-between text-sm">
<span className="font-medium">Obrador Central</span>
<span>{cloneProgress.parent}%</span>
<div className="space-y-6">
{/* Overall Progress Section */}
<div className="text-center">
<div className="flex justify-between text-sm mb-2">
<span className="font-medium">Progreso Total</span>
<span className="font-semibold text-lg">{cloneProgress.overall}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-purple-600 h-3 rounded-full transition-all duration-500 relative overflow-hidden"
style={{ width: `${cloneProgress.overall}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent animate-shimmer"></div>
</div>
</div>
{estimatedRemainingSeconds !== null && estimatedRemainingSeconds > 0 && (
<div className="mt-3 text-sm text-[var(--text-secondary)]">
~{estimatedRemainingSeconds}s restantes
</div>
)}
<div className="mt-4 text-[var(--text-secondary)]">
{getLoadingMessage(creatingTier, cloneProgress.overall)}
</div>
</div>
{/* Enterprise Detailed Progress */}
{creatingTier === 'enterprise' && (
<div className="space-y-5 mt-6">
{/* Parent Tenant */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-4 border border-blue-200 dark:border-blue-800">
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span className="font-semibold text-blue-900 dark:text-blue-100">Obrador Central</span>
</div>
<span className="font-medium text-blue-700 dark:text-blue-300">{cloneProgress.parent}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
<div
className="bg-gradient-to-r from-blue-400 to-blue-600 h-2.5 rounded-full transition-all duration-500"
style={{ width: `${cloneProgress.parent}%` }}
></div>
</div>
</div>
{/* Child Outlets */}
<div className="grid grid-cols-3 gap-3">
{cloneProgress.children.map((progress, index) => (
<div key={index} className="text-center">
<div className="text-xs text-[var(--text-tertiary)] mb-1">Outlet {index + 1}</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
key={index}
className="bg-green-50 dark:bg-green-900/20 rounded-lg p-3 border border-green-200 dark:border-green-800"
>
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-medium text-green-700 dark:text-green-300">Outlet {index + 1}</span>
<span className="text-xs font-semibold text-green-700 dark:text-green-300">{progress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
className="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
></div>
</div>
<div className="text-xs mt-1">{progress}%</div>
</div>
))}
</div>
<div className="flex justify-between text-sm mt-2">
<span className="font-medium">Distribución</span>
<span>{cloneProgress.distribution}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-purple-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${cloneProgress.distribution}%` }}
></div>
{/* Distribution System */}
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-xl p-4 border border-purple-200 dark:border-purple-800">
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-purple-500"></div>
<span className="font-semibold text-purple-900 dark:text-purple-100">Distribución</span>
</div>
<span className="font-medium text-purple-700 dark:text-purple-300">{cloneProgress.distribution}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
<div
className="bg-gradient-to-r from-purple-400 to-purple-600 h-2.5 rounded-full transition-all duration-500"
style={{ width: `${cloneProgress.distribution}%` }}
></div>
</div>
</div>
</div>
)}
{/* Professional Progress Indicator */}
{creatingTier === 'professional' && cloneProgress.overall < 100 && (
<div className="text-center py-3">
<div className="flex justify-center items-center gap-1">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
<p className="text-sm text-[var(--text-tertiary)] mt-2">
Procesando servicios en paralelo...
</p>
</div>
)}
{/* Information Box */}
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<p className="text-xs text-[var(--text-tertiary)] text-center">
{creatingTier === 'enterprise'
? 'Creando obrador central, outlets y sistema de distribución...'
: 'Personalizando tu panadería con datos reales...'}
</p>
</div>
</div>
</ModalBody>
</Modal>

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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()

View File

@@ -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(

View File

@@ -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

View File

@@ -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"""

View File

@@ -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):
"""

View File

@@ -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")

View File

@@ -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'
]

View File

@@ -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"

View File

@@ -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"

View File

@@ -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}"