Fix and UI imporvements
This commit is contained in:
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -33,6 +33,7 @@
|
|||||||
"i18next": "^23.7.0",
|
"i18next": "^23.7.0",
|
||||||
"i18next-icu": "^2.4.1",
|
"i18next-icu": "^2.4.1",
|
||||||
"immer": "^10.0.3",
|
"immer": "^10.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"react-hook-form": "^7.48.0",
|
"react-hook-form": "^7.48.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^13.5.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"recharts": "^2.10.0",
|
"recharts": "^2.10.0",
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
@@ -4038,6 +4040,17 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||||
@@ -11597,6 +11610,13 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@@ -13415,6 +13435,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"i18next": "^23.7.0",
|
"i18next": "^23.7.0",
|
||||||
"i18next-icu": "^2.4.1",
|
"i18next-icu": "^2.4.1",
|
||||||
"immer": "^10.0.3",
|
"immer": "^10.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"react-hook-form": "^7.48.0",
|
"react-hook-form": "^7.48.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^13.5.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"recharts": "^2.10.0",
|
"recharts": "^2.10.0",
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/*
|
/*
|
||||||
* Distribution Map Component for Enterprise Dashboard
|
* 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 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 { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
|
||||||
import { Badge } from '../ui/Badge';
|
import { Badge } from '../ui/Badge';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
@@ -22,6 +25,14 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 {
|
interface RoutePoint {
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -32,6 +43,7 @@ interface RoutePoint {
|
|||||||
estimated_arrival?: string;
|
estimated_arrival?: string;
|
||||||
actual_arrival?: string;
|
actual_arrival?: string;
|
||||||
sequence: number;
|
sequence: number;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteData {
|
interface RouteData {
|
||||||
@@ -40,7 +52,7 @@ interface RouteData {
|
|||||||
total_distance_km: number;
|
total_distance_km: number;
|
||||||
estimated_duration_minutes: number;
|
estimated_duration_minutes: number;
|
||||||
status: 'planned' | 'in_progress' | 'completed' | 'cancelled';
|
status: 'planned' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
route_points?: RoutePoint[];
|
route_sequence?: RoutePoint[]; // Backend returns route_sequence, not route_points
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShipmentStatusData {
|
interface ShipmentStatusData {
|
||||||
@@ -55,13 +67,22 @@ interface DistributionMapProps {
|
|||||||
shipments?: ShipmentStatusData;
|
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> = ({
|
const DistributionMap: React.FC<DistributionMapProps> = ({
|
||||||
routes = [],
|
routes = [],
|
||||||
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
|
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('dashboard');
|
const { t } = useTranslation('dashboard');
|
||||||
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
|
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
|
||||||
const [showAllRoutes, setShowAllRoutes] = useState(true);
|
|
||||||
|
|
||||||
const renderMapVisualization = () => {
|
const renderMapVisualization = () => {
|
||||||
if (!routes || routes.length === 0) {
|
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 =>
|
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) {
|
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 (
|
return (
|
||||||
<div className="relative h-64 lg:h-96 rounded-xl overflow-hidden border-2" style={{
|
<div className="relative h-96 rounded-xl overflow-hidden border-2" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
background: 'linear-gradient(135deg, var(--color-info-50) 0%, var(--color-primary-50) 50%, var(--color-secondary-50) 100%)',
|
<MapContainer
|
||||||
borderColor: 'var(--border-primary)'
|
center={[centerLat, centerLng]}
|
||||||
}}>
|
zoom={6}
|
||||||
{/* Bakery-themed pattern overlay */}
|
style={{ height: '100%', width: '100%' }}
|
||||||
<div className="absolute inset-0 opacity-5 bg-pattern" />
|
scrollWheelZoom={true}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Central Info Display */}
|
{/* Render each route */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
{activeRoutes.map((route, routeIdx) => {
|
||||||
<div className="text-center">
|
const points = route.route_sequence || [];
|
||||||
<div
|
const routeColor = route.status === 'in_progress' ? '#3b82f6' : '#f59e0b';
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Glassmorphism Route Info Cards */}
|
// Create polyline coordinates
|
||||||
<div className="absolute top-4 left-4 right-4 flex flex-wrap gap-2">
|
const polylinePositions: [number, number][] = points.map(p => [p.latitude, p.longitude]);
|
||||||
{activeRoutes.slice(0, 3).map((route, index) => (
|
|
||||||
<div
|
return (
|
||||||
key={route.id}
|
<React.Fragment key={route.id}>
|
||||||
className="glass-effect p-3 rounded-lg shadow-md backdrop-blur-sm max-w-xs"
|
{/* Route polyline */}
|
||||||
style={{
|
<Polyline
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
positions={polylinePositions}
|
||||||
border: '1px solid var(--border-primary)'
|
pathOptions={{
|
||||||
}}
|
color: routeColor,
|
||||||
>
|
weight: 4,
|
||||||
<div className="flex items-center gap-2">
|
opacity: 0.7
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-info-100)',
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<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
|
<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={{
|
style={{
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
border: '1px solid var(--border-primary)'
|
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="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)' }}>
|
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{t('enterprise.pending')}: {shipments.pending}
|
{t('enterprise.delivered')}: {shipments.delivered}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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)' }}>
|
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{t('enterprise.in_transit')}: {shipments.in_transit}
|
{t('enterprise.in_transit')}: {shipments.in_transit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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)' }}>
|
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{t('enterprise.delivered')}: {shipments.delivered}
|
{t('enterprise.pending')}: {shipments.pending}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{shipments.failed > 0 && (
|
{shipments.failed > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<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)' }}>
|
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{t('enterprise.failed')}: {shipments.failed}
|
{t('enterprise.failed')}: {shipments.failed}
|
||||||
</span>
|
</span>
|
||||||
@@ -437,9 +456,9 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline of Stops */}
|
{/* 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)' }}>
|
<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) => {
|
const getPointStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'delivered':
|
case 'delivered':
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export function useEventNotifications(config: UseNotificationsConfig = {}): UseE
|
|||||||
notifications,
|
notifications,
|
||||||
recentNotifications,
|
recentNotifications,
|
||||||
isLoading: isLoading || !isConnected,
|
isLoading: isLoading || !isConnected,
|
||||||
|
isConnected, // Added this missing return property
|
||||||
clearNotifications,
|
clearNotifications,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
MapPin,
|
MapPin,
|
||||||
Calendar,
|
Calendar,
|
||||||
ArrowRight,
|
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle
|
AlertTriangle,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -22,8 +21,10 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Badge,
|
Badge,
|
||||||
Input
|
Input,
|
||||||
|
Tabs,
|
||||||
} from '../../../../components/ui';
|
} from '../../../../components/ui';
|
||||||
|
import { TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { useTenant } from '../../../../stores/tenant.store';
|
import { useTenant } from '../../../../stores/tenant.store';
|
||||||
import { useDistributionOverview } from '../../../../api/hooks/useEnterpriseDashboard';
|
import { useDistributionOverview } from '../../../../api/hooks/useEnterpriseDashboard';
|
||||||
@@ -118,69 +119,60 @@ const DistributionPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content Areas */}
|
{/* Main Content Areas */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* Tabs Navigation */}
|
{/* Tabs Navigation */}
|
||||||
<div className="flex border-b border-gray-200">
|
<Tabs
|
||||||
<button
|
value={activeTab}
|
||||||
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'overview'
|
onValueChange={(value: 'overview' | 'routes' | 'shipments') => setActiveTab(value)}
|
||||||
? 'border-blue-500 text-blue-600'
|
className="w-full"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
>
|
||||||
}`}
|
<TabsList className="flex w-full">
|
||||||
onClick={() => setActiveTab('overview')}
|
<TabsTrigger value="overview" className="flex-1">
|
||||||
>
|
{t('operations:distribution.tabs.overview', 'Vista General')}
|
||||||
Vista General
|
</TabsTrigger>
|
||||||
</button>
|
<TabsTrigger value="routes" className="flex-1">
|
||||||
<button
|
{t('operations:distribution.tabs.routes', 'Listado de Rutas')}
|
||||||
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'routes'
|
</TabsTrigger>
|
||||||
? 'border-blue-500 text-blue-600'
|
<TabsTrigger value="shipments" className="flex-1">
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
{t('operations:distribution.tabs.shipments', 'Listado de Envíos')}
|
||||||
}`}
|
</TabsTrigger>
|
||||||
onClick={() => setActiveTab('routes')}
|
</TabsList>
|
||||||
>
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Content based on Active Tab */}
|
{/* Content based on Active Tab */}
|
||||||
{activeTab === 'overview' && (
|
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Map Section */}
|
{/* Map Section */}
|
||||||
<Card className="overflow-hidden border-none shadow-lg">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="bg-white border-b sticky top-0 z-10">
|
<CardHeader className="sticky top-0 z-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-100 rounded-lg">
|
<div className="p-2 rounded-lg bg-[var(--color-info-50)] dark:bg-[var(--color-info-900)]">
|
||||||
<MapPin className="w-5 h-5 text-blue-600" />
|
<MapPin className="w-5 h-5 text-[var(--color-info)] dark:text-[var(--color-info-300)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>{t('operations:map.title', 'Mapa de Distribución')}</CardTitle>
|
<CardTitle className="text-lg">
|
||||||
<p className="text-sm text-gray-500">Visualización en tiempo real de la flota</p>
|
{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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
En Vivo
|
{t('operations:map.live', 'En Vivo')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="p-4 bg-slate-50">
|
<div className="p-4 bg-[var(--bg-secondary)]">
|
||||||
<DistributionMap
|
<div className="aspect-video rounded-lg overflow-hidden bg-[var(--bg-tertiary)] flex items-center justify-center">
|
||||||
routes={distributionData?.route_sequences || []}
|
<DistributionMap
|
||||||
shipments={shipmentStatus}
|
routes={distributionData?.route_sequences || []}
|
||||||
/>
|
shipments={shipmentStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -189,7 +181,9 @@ const DistributionPage: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Rutas en Progreso</CardTitle>
|
<CardTitle className="text-lg">
|
||||||
|
{t('operations:distribution.active_routes', 'Rutas en Progreso')}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length > 0 ? (
|
{distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length > 0 ? (
|
||||||
@@ -197,101 +191,190 @@ const DistributionPage: React.FC = () => {
|
|||||||
{distributionData.route_sequences
|
{distributionData.route_sequences
|
||||||
.filter((r: any) => r.status === 'in_progress')
|
.filter((r: any) => r.status === 'in_progress')
|
||||||
.map((route: any) => (
|
.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="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-50 rounded-full">
|
<div className="p-2 rounded-full bg-[var(--color-info-50)] dark:bg-[var(--color-info-900)]">
|
||||||
<Truck className="w-4 h-4 text-blue-600" />
|
<Truck
|
||||||
|
className="w-4 h-4 text-[var(--color-info)] dark:text-[var(--color-info-300)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm text-gray-900">Ruta {route.route_number}</p>
|
<p className="font-medium text-[var(--text-primary)]">
|
||||||
<p className="text-xs text-gray-500">{route.formatted_driver_name || 'Sin conductor asignado'}</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="info">En Ruta</Badge>
|
<Badge variant="info">
|
||||||
|
{t('operations:distribution.status.in_progress', 'En Ruta')}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||||
No hay rutas en progreso actualmente.
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'routes' && (
|
<TabsContent value="routes" className="space-y-6 mt-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<CardTitle>Listado de Rutas</CardTitle>
|
<div>
|
||||||
<div className="flex gap-2">
|
<CardTitle className="text-lg">
|
||||||
<Input
|
{t('operations:distribution.routes_list', 'Listado de Rutas')}
|
||||||
placeholder="Buscar rutas..."
|
</CardTitle>
|
||||||
leftIcon={<Search className="w-4 h-4 text-gray-400" />}
|
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||||
className="w-64"
|
{t('operations:distribution.routes_desc', 'Gestión y seguimiento de rutas de distribución')}
|
||||||
/>
|
</p>
|
||||||
<Button variant="outline" size="sm" leftIcon={<Filter className="w-4 h-4" />}>Filtros</Button>
|
</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>
|
||||||
</div>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
{(distributionData?.route_sequences?.length || 0) > 0 ? (
|
||||||
{(distributionData?.route_sequences?.length || 0) > 0 ? (
|
<div className="overflow-x-auto">
|
||||||
<div className="overflow-x-auto">
|
<table className="w-full text-sm text-left">
|
||||||
<table className="w-full text-sm text-left">
|
<thead className="text-xs text-[var(--text-secondary)] uppercase bg-[var(--bg-secondary)] border-b">
|
||||||
<thead className="text-xs text-gray-700 uppercase bg-gray-50">
|
<tr>
|
||||||
<tr>
|
<th className="px-4 py-3">{t('operations:distribution.table.route', 'Ruta')}</th>
|
||||||
<th className="px-4 py-3">Ruta</th>
|
<th className="px-4 py-3">{t('operations:distribution.table.status', 'Estado')}</th>
|
||||||
<th className="px-4 py-3">Estado</th>
|
<th className="px-4 py-3">{t('operations:distribution.table.distance', 'Distancia')}</th>
|
||||||
<th className="px-4 py-3">Distancia</th>
|
<th className="px-4 py-3">{t('operations:distribution.table.duration', 'Duración Est.')}</th>
|
||||||
<th className="px-4 py-3">Duración Est.</th>
|
<th className="px-4 py-3">{t('operations:distribution.table.stops', 'Paradas')}</th>
|
||||||
<th className="px-4 py-3">Paradas</th>
|
<th className="px-4 py-3 text-right">{t('operations:distribution.table.actions', 'Acciones')}</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-[var(--border-primary)]">
|
||||||
</table>
|
{distributionData.route_sequences.map((route: any) => (
|
||||||
</div>
|
<tr key={route.id} className="hover:bg-[var(--bg-secondary)] transition-colors">
|
||||||
) : (
|
<td className="px-4 py-3 font-medium text-[var(--text-primary)]">
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed">
|
{t('operations:distribution.route_prefix', 'Ruta')} {route.route_number}
|
||||||
<p className="text-gray-500">No se encontraron rutas para esta fecha.</p>
|
</td>
|
||||||
</div>
|
<td className="px-4 py-3">
|
||||||
)}
|
<Badge variant={
|
||||||
</CardContent>
|
route.status === 'completed' ? 'success' :
|
||||||
</Card>
|
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 */}
|
{/* Similar structure for Shipments tab, simplified for now */}
|
||||||
{activeTab === 'shipments' && (
|
<TabsContent value="shipments" className="space-y-6 mt-6">
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed">
|
<Card>
|
||||||
<Package className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
<CardHeader>
|
||||||
<h3 className="text-lg font-medium text-gray-900">Gestión de Envíos</h3>
|
<CardTitle className="text-lg">
|
||||||
<p className="text-gray-500">Funcionalidad de listado detallado de envíos próximamente.</p>
|
{t('operations:distribution.shipments_list', 'Gestión de Envíos')}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const DemoPage = () => {
|
|||||||
const [creationError, setCreationError] = useState('');
|
const [creationError, setCreationError] = useState('');
|
||||||
const [estimatedProgress, setEstimatedProgress] = useState(0);
|
const [estimatedProgress, setEstimatedProgress] = useState(0);
|
||||||
const [progressStartTime, setProgressStartTime] = useState<number | null>(null);
|
const [progressStartTime, setProgressStartTime] = useState<number | null>(null);
|
||||||
|
const [estimatedRemainingSeconds, setEstimatedRemainingSeconds] = useState<number | null>(null);
|
||||||
|
|
||||||
// BUG-010 FIX: State for partial status warning
|
// BUG-010 FIX: State for partial status warning
|
||||||
const [partialWarning, setPartialWarning] = useState<{
|
const [partialWarning, setPartialWarning] = useState<{
|
||||||
@@ -227,19 +228,19 @@ const DemoPage = () => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating demo:', error);
|
console.error('Error creating demo:', error);
|
||||||
setCreationError('Error al iniciar la demo. Por favor, inténtalo de nuevo.');
|
|
||||||
} finally {
|
|
||||||
setCreatingTier(null);
|
setCreatingTier(null);
|
||||||
setProgressStartTime(null);
|
setProgressStartTime(null);
|
||||||
setEstimatedProgress(0);
|
setEstimatedProgress(0);
|
||||||
// Reset progress
|
|
||||||
setCloneProgress({
|
setCloneProgress({
|
||||||
parent: 0,
|
parent: 0,
|
||||||
children: [0, 0, 0],
|
children: [0, 0, 0],
|
||||||
distribution: 0,
|
distribution: 0,
|
||||||
overall: 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) => {
|
const pollForSessionStatus = async (sessionId, tier, sessionData) => {
|
||||||
@@ -287,17 +288,33 @@ const DemoPage = () => {
|
|||||||
|
|
||||||
const statusData = await statusResponse.json();
|
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
|
// Update progress based on actual backend status
|
||||||
updateProgressFromBackendStatus(statusData, tier);
|
updateProgressFromBackendStatus(statusData, tier);
|
||||||
|
|
||||||
// BUG-010 FIX: Handle ready status separately from partial
|
// BUG-010 FIX: Handle ready status separately from partial
|
||||||
if (statusData.status === 'ready') {
|
if (statusData.status === 'ready') {
|
||||||
// Full success - navigate immediately
|
// Full success - set to 100% and navigate after delay
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
|
setCloneProgress(prev => ({ ...prev, overall: 100 }));
|
||||||
setTimeout(() => {
|
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 to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
||||||
navigate('/app/dashboard');
|
navigate('/app/dashboard');
|
||||||
}, 1000);
|
}, 1500); // Increased from 1000ms to show 100% completion
|
||||||
return;
|
return;
|
||||||
} else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') {
|
} else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') {
|
||||||
// BUG-010 FIX: Show warning modal for partial status
|
// BUG-010 FIX: Show warning modal for partial status
|
||||||
@@ -313,6 +330,15 @@ const DemoPage = () => {
|
|||||||
return;
|
return;
|
||||||
} else if (statusData.status === 'FAILED' || statusData.status === 'failed') {
|
} else if (statusData.status === 'FAILED' || statusData.status === 'failed') {
|
||||||
clearInterval(progressInterval);
|
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.');
|
setCreationError('Error al clonar los datos de demo. Por favor, inténtalo de nuevo.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -343,6 +369,15 @@ const DemoPage = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
console.error('Error polling for status:', error);
|
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.');
|
setCreationError('Error verificando el estado de la demo. Por favor, inténtalo de nuevo.');
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up abort controller reference
|
// Clean up abort controller reference
|
||||||
@@ -466,7 +501,9 @@ const DemoPage = () => {
|
|||||||
if (progress.parent && progress.children && progress.distribution !== undefined) {
|
if (progress.parent && progress.children && progress.distribution !== undefined) {
|
||||||
// This looks like an enterprise results structure from the end of cloning
|
// This looks like an enterprise results structure from the end of cloning
|
||||||
// Calculate progress based on parent, children, and distribution status
|
// 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;
|
parentProgress = 100;
|
||||||
} else if (progress.parent.overall_status === 'pending') {
|
} else if (progress.parent.overall_status === 'pending') {
|
||||||
parentProgress = 50; // Increased from 25 for better perceived progress
|
parentProgress = 50; // Increased from 25 for better perceived progress
|
||||||
@@ -482,9 +519,11 @@ const DemoPage = () => {
|
|||||||
|
|
||||||
if (progress.children && progress.children.length > 0) {
|
if (progress.children && progress.children.length > 0) {
|
||||||
childrenProgressArray = progress.children.map((child: any) => {
|
childrenProgressArray = progress.children.map((child: any) => {
|
||||||
if (child.status === 'ready' || child.status === 'completed') return 100;
|
// FIX 2: Handle both status types for children
|
||||||
if (child.status === 'partial') return 75;
|
const childStatus = child.status || child.overall_status;
|
||||||
if (child.status === 'pending') return 30;
|
if (childStatus === 'ready' || childStatus === 'completed') return 100;
|
||||||
|
if (childStatus === 'partial') return 75;
|
||||||
|
if (childStatus === 'pending') return 30;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
const avgChildrenProgress = childrenProgressArray.reduce((a, b) => a + b, 0) / childrenProgressArray.length;
|
const avgChildrenProgress = childrenProgressArray.reduce((a, b) => a + b, 0) / childrenProgressArray.length;
|
||||||
@@ -499,15 +538,22 @@ const DemoPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (progress.distribution) {
|
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;
|
distributionProgress = 100;
|
||||||
} else if (progress.distribution.status === 'pending') {
|
} else if (distStatus === 'pending') {
|
||||||
distributionProgress = 50;
|
distributionProgress = 50;
|
||||||
} else {
|
} else {
|
||||||
distributionProgress = progress.distribution.status === 'failed' ? 100 : 75;
|
distributionProgress = distStatus === 'failed' ? 100 : 75;
|
||||||
}
|
}
|
||||||
backendProgress = Math.round(backendProgress * 0.8 + distributionProgress * 0.2);
|
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 {
|
} else {
|
||||||
// If it's not the enterprise result structure, fall back to service-based calculation
|
// If it's not the enterprise result structure, fall back to service-based calculation
|
||||||
const services = progress || {};
|
const services = progress || {};
|
||||||
@@ -525,8 +571,9 @@ const DemoPage = () => {
|
|||||||
distributionProgress = backendProgress * 0.8;
|
distributionProgress = backendProgress * 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the maximum of backend progress and estimated progress to prevent backtracking
|
// FIX 5: Don't cap at 95% when backend reports 100%
|
||||||
const overallProgress = Math.max(Math.min(95, backendProgress), estimatedProgress);
|
const cappedBackendProgress = backendProgress === 100 ? 100 : Math.min(95, backendProgress);
|
||||||
|
const overallProgress = Math.max(cappedBackendProgress, estimatedProgress);
|
||||||
|
|
||||||
setCloneProgress({
|
setCloneProgress({
|
||||||
parent: Math.max(parentProgress, estimatedProgress * 0.9),
|
parent: Math.max(parentProgress, estimatedProgress * 0.9),
|
||||||
@@ -681,61 +728,127 @@ const DemoPage = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={creatingTier !== null}
|
isOpen={creatingTier !== null}
|
||||||
onClose={() => { }}
|
onClose={() => { }}
|
||||||
size="md"
|
size="lg"
|
||||||
>
|
>
|
||||||
<ModalHeader
|
<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}
|
showCloseButton={false}
|
||||||
/>
|
/>
|
||||||
<ModalBody padding="lg">
|
<ModalBody padding="lg">
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between text-sm">
|
{/* Overall Progress Section */}
|
||||||
<span>Progreso total</span>
|
<div className="text-center">
|
||||||
<span>{cloneProgress.overall}%</span>
|
<div className="flex justify-between text-sm mb-2">
|
||||||
</div>
|
<span className="font-medium">Progreso Total</span>
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<span className="font-semibold text-lg">{cloneProgress.overall}%</span>
|
||||||
<div
|
</div>
|
||||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
style={{ width: `${cloneProgress.overall}%` }}
|
<div
|
||||||
></div>
|
className="bg-gradient-to-r from-blue-500 to-purple-600 h-3 rounded-full transition-all duration-500 relative overflow-hidden"
|
||||||
</div>
|
style={{ width: `${cloneProgress.overall}%` }}
|
||||||
|
>
|
||||||
<div className="text-center text-sm text-[var(--text-secondary)] mt-4">
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent animate-shimmer"></div>
|
||||||
{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>
|
</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">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{cloneProgress.children.map((progress, index) => (
|
{cloneProgress.children.map((progress, index) => (
|
||||||
<div key={index} className="text-center">
|
<div
|
||||||
<div className="text-xs text-[var(--text-tertiary)] mb-1">Outlet {index + 1}</div>
|
key={index}
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
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
|
<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}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs mt-1">{progress}%</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm mt-2">
|
|
||||||
<span className="font-medium">Distribución</span>
|
{/* Distribution System */}
|
||||||
<span>{cloneProgress.distribution}%</span>
|
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-xl p-4 border border-purple-200 dark:border-purple-800">
|
||||||
</div>
|
<div className="flex justify-between items-center mb-2">
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div className="w-3 h-3 rounded-full bg-purple-500"></div>
|
||||||
className="bg-purple-500 h-1.5 rounded-full transition-all duration-300"
|
<span className="font-semibold text-purple-900 dark:text-purple-100">Distribución</span>
|
||||||
style={{ width: `${cloneProgress.distribution}%` }}
|
</div>
|
||||||
></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>
|
||||||
</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>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import structlog
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import AsyncSessionLocal
|
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.services.enrichment_orchestrator import EnrichmentOrchestrator
|
||||||
from app.repositories.event_repository import EventRepository
|
from app.repositories.event_repository import EventRepository
|
||||||
from shared.clients.notification_client import create_notification_client
|
from shared.clients.notification_client import create_notification_client
|
||||||
|
|||||||
@@ -115,9 +115,18 @@ class BusinessImpactAnalyzer:
|
|||||||
"""Analyze impact of procurement-related alerts"""
|
"""Analyze impact of procurement-related alerts"""
|
||||||
impact = {}
|
impact = {}
|
||||||
|
|
||||||
# PO amount as financial impact
|
# Extract potential_loss_eur from reasoning_data.parameters
|
||||||
po_amount = metadata.get("po_amount", metadata.get("total_amount", 0))
|
reasoning_data = metadata.get("reasoning_data", {})
|
||||||
impact["financial_impact_eur"] = float(po_amount)
|
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 affects customer impact
|
||||||
days_overdue = metadata.get("days_overdue", 0)
|
days_overdue = metadata.get("days_overdue", 0)
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class UrgencyAnalyzer:
|
|||||||
elif "delivery" in event_type or "overdue" in event_type:
|
elif "delivery" in event_type or "overdue" in event_type:
|
||||||
urgency.update(self._analyze_delivery_urgency(metadata))
|
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
|
# Check for explicit deadlines
|
||||||
if "required_delivery_date" in metadata:
|
if "required_delivery_date" in metadata:
|
||||||
urgency.update(self._calculate_deadline_urgency(metadata["required_delivery_date"]))
|
urgency.update(self._calculate_deadline_urgency(metadata["required_delivery_date"]))
|
||||||
@@ -115,6 +118,38 @@ class UrgencyAnalyzer:
|
|||||||
|
|
||||||
return urgency
|
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:
|
def _calculate_deadline_urgency(self, deadline_str: str) -> dict:
|
||||||
"""Calculate urgency based on deadline"""
|
"""Calculate urgency based on deadline"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Dict, Any
|
|||||||
import structlog
|
import structlog
|
||||||
from uuid import uuid4
|
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.schemas.events import EnrichedEvent, I18nContent, BusinessImpact, Urgency, UserAgency, OrchestratorContext
|
||||||
from app.enrichment.message_generator import MessageGenerator
|
from app.enrichment.message_generator import MessageGenerator
|
||||||
from app.enrichment.priority_scorer import PriorityScorer
|
from app.enrichment.priority_scorer import PriorityScorer
|
||||||
|
|||||||
@@ -130,50 +130,85 @@ class ProfessionalCloningStrategy(CloningStrategy):
|
|||||||
tasks.append(task)
|
tasks.append(task)
|
||||||
service_map[task] = service_def.name
|
service_map[task] = service_def.name
|
||||||
|
|
||||||
# Wait for all tasks to complete
|
# Process tasks as they complete for real-time progress updates
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
# Process results
|
|
||||||
service_results = {}
|
service_results = {}
|
||||||
total_records = 0
|
total_records = 0
|
||||||
failed_services = []
|
failed_services = []
|
||||||
required_service_failed = False
|
required_service_failed = False
|
||||||
|
completed_count = 0
|
||||||
|
total_count = len(tasks)
|
||||||
|
|
||||||
for task, result in zip(tasks, results):
|
# Create a mapping from futures to service names to properly identify completed tasks
|
||||||
service_name = service_map[task]
|
# We'll use asyncio.wait approach instead of as_completed to access the original tasks
|
||||||
service_def = next(s for s in services_to_clone if s.name == service_name)
|
pending = set(tasks)
|
||||||
|
completed_tasks_info = {task: service_map[task] for task in tasks} # Map tasks to service names
|
||||||
|
|
||||||
if isinstance(result, Exception):
|
while pending:
|
||||||
logger.error(
|
# Wait for at least one task to complete
|
||||||
f"Service {service_name} cloning failed with exception",
|
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
||||||
session_id=context.session_id,
|
|
||||||
error=str(result)
|
# Process each completed task
|
||||||
)
|
for completed_task in done:
|
||||||
service_results[service_name] = {
|
try:
|
||||||
"status": "failed",
|
# Get the result from the completed task
|
||||||
"error": str(result),
|
result = await completed_task
|
||||||
"records_cloned": 0
|
# Get the service name from our mapping
|
||||||
}
|
service_name = completed_tasks_info[completed_task]
|
||||||
failed_services.append(service_name)
|
service_def = next(s for s in services_to_clone if s.name == service_name)
|
||||||
if service_def.required:
|
|
||||||
required_service_failed = True
|
service_results[service_name] = result
|
||||||
else:
|
completed_count += 1
|
||||||
service_results[service_name] = result
|
|
||||||
if result.get("status") == "failed":
|
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)
|
failed_services.append(service_name)
|
||||||
|
completed_count += 1
|
||||||
if service_def.required:
|
if service_def.required:
|
||||||
required_service_failed = True
|
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
|
# Determine overall status
|
||||||
if required_service_failed:
|
if required_service_failed:
|
||||||
@@ -475,7 +510,7 @@ class EnterpriseCloningStrategy(CloningStrategy):
|
|||||||
elif failed_children > 0:
|
elif failed_children > 0:
|
||||||
overall_status = "partial"
|
overall_status = "partial"
|
||||||
else:
|
else:
|
||||||
overall_status = "ready"
|
overall_status = "completed" # Changed from "ready" to match professional strategy
|
||||||
|
|
||||||
# Calculate total records cloned (parent + all children)
|
# Calculate total records cloned (parent + all children)
|
||||||
total_records_cloned = parent_result.get("total_records", 0)
|
total_records_cloned = parent_result.get("total_records", 0)
|
||||||
|
|||||||
@@ -464,6 +464,14 @@ class DemoSessionManager:
|
|||||||
"""Cache session status in Redis for fast status checks"""
|
"""Cache session status in Redis for fast status checks"""
|
||||||
status_key = f"session:{session.session_id}:status"
|
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 = {
|
status_data = {
|
||||||
"session_id": session.session_id,
|
"session_id": session.session_id,
|
||||||
"status": session.status.value,
|
"status": session.status.value,
|
||||||
@@ -471,7 +479,9 @@ class DemoSessionManager:
|
|||||||
"total_records_cloned": session.total_records_cloned,
|
"total_records_cloned": session.total_records_cloned,
|
||||||
"cloning_started_at": session.cloning_started_at.isoformat() if session.cloning_started_at else None,
|
"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,
|
"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
|
import json as json_module
|
||||||
@@ -508,6 +518,14 @@ class DemoSessionManager:
|
|||||||
|
|
||||||
await self._cache_session_status(session)
|
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 {
|
return {
|
||||||
"session_id": session.session_id,
|
"session_id": session.session_id,
|
||||||
"status": session.status.value,
|
"status": session.status.value,
|
||||||
@@ -515,7 +533,9 @@ class DemoSessionManager:
|
|||||||
"total_records_cloned": session.total_records_cloned,
|
"total_records_cloned": session.total_records_cloned,
|
||||||
"cloning_started_at": session.cloning_started_at.isoformat() if session.cloning_started_at else None,
|
"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,
|
"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(
|
async def retry_failed_cloning(
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ from typing import List, Dict, Any
|
|||||||
# Add project root to path
|
# Add project root to path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
from shared.messaging import RabbitMQClient
|
from shared.messaging import RabbitMQClient, AlertTypeConstants
|
||||||
from shared.schemas.alert_types import AlertTypeConstants
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|||||||
@@ -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
|
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
|
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 = [
|
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"},
|
"tenant_id": str(DEMO_TENANT_CHILD_1),
|
||||||
{"stop": 3, "tenant_id": str(DEMO_TENANT_CHILD_3), "location": "Valencia Ruzafa"}
|
"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
|
# Route status (already determined is_past above)
|
||||||
# Past routes are completed, today and future routes are planned
|
|
||||||
is_past = delivery_date < BASE_REFERENCE_DATE
|
|
||||||
route_status = DeliveryRouteStatus.completed if is_past else DeliveryRouteStatus.planned
|
route_status = DeliveryRouteStatus.completed if is_past else DeliveryRouteStatus.planned
|
||||||
|
|
||||||
route = DeliveryRoute(
|
route = DeliveryRoute(
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class OrchestratorSettings(BaseServiceSettings):
|
|||||||
# Orchestration Settings
|
# Orchestration Settings
|
||||||
ORCHESTRATION_ENABLED: bool = os.getenv("ORCHESTRATION_ENABLED", "true").lower() == "true"
|
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_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
|
ORCHESTRATION_TIMEOUT_SECONDS: int = int(os.getenv("ORCHESTRATION_TIMEOUT_SECONDS", "600")) # 10 minutes
|
||||||
|
|
||||||
# Tenant Processing
|
# Tenant Processing
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from datetime import datetime, date, timezone
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
import structlog
|
import structlog
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
# Updated imports - removed old alert system
|
# Updated imports - removed old alert system
|
||||||
@@ -51,6 +52,9 @@ class OrchestratorSchedulerService:
|
|||||||
self.publisher = event_publisher
|
self.publisher = event_publisher
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
# APScheduler instance for running daily orchestration
|
||||||
|
self.scheduler = None
|
||||||
|
|
||||||
# Service clients
|
# Service clients
|
||||||
self.forecast_client = ForecastServiceClient(config, "orchestrator-service")
|
self.forecast_client = ForecastServiceClient(config, "orchestrator-service")
|
||||||
self.production_client = ProductionServiceClient(config, "orchestrator-service")
|
self.production_client = ProductionServiceClient(config, "orchestrator-service")
|
||||||
@@ -670,13 +674,46 @@ class OrchestratorSchedulerService:
|
|||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the orchestrator scheduler service"""
|
"""Start the orchestrator scheduler service"""
|
||||||
logger.info("OrchestratorSchedulerService started")
|
if not settings.ORCHESTRATION_ENABLED:
|
||||||
# Add any initialization logic here if needed
|
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):
|
async def stop(self):
|
||||||
"""Stop the orchestrator scheduler service"""
|
"""Stop the orchestrator scheduler service"""
|
||||||
logger.info("OrchestratorSchedulerService stopped")
|
if self.scheduler and self.scheduler.running:
|
||||||
# Add any cleanup logic here if needed
|
self.scheduler.shutdown(wait=True)
|
||||||
|
logger.info("OrchestratorSchedulerService stopped")
|
||||||
|
else:
|
||||||
|
logger.info("OrchestratorSchedulerService already stopped")
|
||||||
|
|
||||||
def get_circuit_breaker_stats(self) -> Dict[str, Any]:
|
def get_circuit_breaker_stats(self) -> Dict[str, Any]:
|
||||||
"""Get circuit breaker statistics for monitoring"""
|
"""Get circuit breaker statistics for monitoring"""
|
||||||
|
|||||||
@@ -42,18 +42,41 @@ class DeliveryTrackingService:
|
|||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the delivery tracking scheduler"""
|
"""Start the delivery tracking scheduler"""
|
||||||
|
# Initialize and start scheduler if not already running
|
||||||
if not self.scheduler.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()
|
self.scheduler.start()
|
||||||
logger.info(
|
|
||||||
"Delivery tracking scheduler started",
|
# Log next run time
|
||||||
instance_id=self.instance_id
|
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):
|
async def stop(self):
|
||||||
"""Stop the scheduler and release leader lock"""
|
"""Stop the scheduler and release leader lock"""
|
||||||
if self.scheduler.running:
|
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)
|
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):
|
async def _check_all_tenants(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ async def get_batch_details(
|
|||||||
from app.repositories.production_batch_repository import ProductionBatchRepository
|
from app.repositories.production_batch_repository import ProductionBatchRepository
|
||||||
batch_repo = ProductionBatchRepository(db)
|
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):
|
if not batch or str(batch.tenant_id) != str(tenant_id):
|
||||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,26 @@ from .messaging_client import (
|
|||||||
EVENT_TYPES
|
EVENT_TYPES
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
MinimalEvent,
|
||||||
|
EventDomain,
|
||||||
|
EventClass,
|
||||||
|
Severity,
|
||||||
|
AlertTypeConstants
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'RabbitMQClient',
|
'RabbitMQClient',
|
||||||
'UnifiedEventPublisher',
|
'UnifiedEventPublisher',
|
||||||
'ServiceMessagingManager',
|
'ServiceMessagingManager',
|
||||||
'initialize_service_publisher',
|
'initialize_service_publisher',
|
||||||
'cleanup_service_publisher',
|
'cleanup_service_publisher',
|
||||||
'EventMessage',
|
'EventMessage',
|
||||||
'EventType',
|
'EventType',
|
||||||
'EVENT_TYPES'
|
'EVENT_TYPES',
|
||||||
|
'MinimalEvent',
|
||||||
|
'EventDomain',
|
||||||
|
'EventClass',
|
||||||
|
'Severity',
|
||||||
|
'AlertTypeConstants'
|
||||||
]
|
]
|
||||||
@@ -4,12 +4,14 @@ Minimal event schemas for services to emit events.
|
|||||||
Services send minimal event data with only event_type and metadata.
|
Services send minimal event data with only event_type and metadata.
|
||||||
All enrichment, i18n generation, and priority calculation happens
|
All enrichment, i18n generation, and priority calculation happens
|
||||||
in the alert_processor service.
|
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 pydantic import BaseModel, Field
|
||||||
from typing import Dict, Any, Literal, Optional
|
from typing import Dict, Any, Literal, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
|
|
||||||
class MinimalEvent(BaseModel):
|
class MinimalEvent(BaseModel):
|
||||||
@@ -116,7 +118,10 @@ class MinimalEvent(BaseModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# Event Domain Constants
|
# Event Domain Constants
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
class EventDomain:
|
class EventDomain:
|
||||||
"""Standard event domains"""
|
"""Standard event domains"""
|
||||||
INVENTORY = "inventory"
|
INVENTORY = "inventory"
|
||||||
@@ -128,7 +133,10 @@ class EventDomain:
|
|||||||
FINANCE = "finance"
|
FINANCE = "finance"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# Event Class Constants
|
# Event Class Constants
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
class EventClass:
|
class EventClass:
|
||||||
"""Event classifications"""
|
"""Event classifications"""
|
||||||
ALERT = "alert" # Requires user decision/action
|
ALERT = "alert" # Requires user decision/action
|
||||||
@@ -136,7 +144,10 @@ class EventClass:
|
|||||||
RECOMMENDATION = "recommendation" # Optimization suggestion
|
RECOMMENDATION = "recommendation" # Optimization suggestion
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# Severity Levels (for routing)
|
# Severity Levels (for routing)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
class Severity:
|
class Severity:
|
||||||
"""Alert severity levels for routing"""
|
"""Alert severity levels for routing"""
|
||||||
URGENT = "urgent" # Immediate attention required
|
URGENT = "urgent" # Immediate attention required
|
||||||
@@ -144,3 +155,37 @@ class Severity:
|
|||||||
MEDIUM = "medium" # Standard priority
|
MEDIUM = "medium" # Standard priority
|
||||||
LOW = "low" # Minor, can wait
|
LOW = "low" # Minor, can wait
|
||||||
INFO = "info" # Informational only
|
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"
|
||||||
@@ -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"
|
|
||||||
@@ -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}"
|
|
||||||
Reference in New Issue
Block a user