Imporve enterprise

This commit is contained in:
Urtzi Alfaro
2025-12-17 20:50:22 +01:00
parent e3ef47b879
commit f8591639a7
28 changed files with 6802 additions and 258 deletions

View File

@@ -0,0 +1,195 @@
# Deployment Troubleshooting Guide
This guide addresses common deployment issues encountered with the Bakery IA system.
## Table of Contents
- [Too Many Open Files Error](#too-many-open-files-error)
- [RouteBuilder TypeError Fix](#routebuilder-typeerror-fix)
- [General Kubernetes Troubleshooting](#general-kubernetes-troubleshooting)
## Too Many Open Files Error
### Symptoms
```
failed to create fsnotify watcher: too many open files
Error streaming distribution-service-7ff4db8c48-k4xw7 logs: failed to create fsnotify watcher: too many open files
```
### Root Cause
This error occurs when the system hits inotify limits, which are used by Kubernetes and Docker to monitor file system changes. This is common in development environments with many containers.
### Solutions
#### For macOS (Docker Desktop)
1. **Increase Docker Resources**:
- Open Docker Desktop
- Go to Settings > Resources > Advanced
- Increase memory allocation to 8GB or more
- Restart Docker Desktop
2. **Clean Docker System**:
```bash
docker system prune -a --volumes
```
3. **Adjust macOS System Limits**:
```bash
# Add to /etc/sysctl.conf
echo "kern.maxfiles=1048576" | sudo tee -a /etc/sysctl.conf
echo "kern.maxfilesperproc=65536" | sudo tee -a /etc/sysctl.conf
# Apply changes
sudo sysctl -w kern.maxfiles=1048576
sudo sysctl -w kern.maxfilesperproc=65536
```
#### For Linux (Kubernetes Nodes)
1. **Temporary Fix**:
```bash
sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=1024
sudo sysctl -w fs.inotify.max_queued_events=16384
```
2. **Permanent Fix**:
```bash
# Add to /etc/sysctl.conf
echo "fs.inotify.max_user_watches=524288" | sudo tee -a /etc/sysctl.conf
echo "fs.inotify.max_user_instances=1024" | sudo tee -a /etc/sysctl.conf
echo "fs.inotify.max_queued_events=16384" | sudo tee -a /etc/sysctl.conf
# Apply changes
sudo sysctl -p
```
3. **Restart Kubernetes Components**:
```bash
sudo systemctl restart kubelet
sudo systemctl restart docker
```
#### For Kind Clusters
```bash
# Delete and recreate cluster
kind delete cluster
kind create cluster
```
#### For Minikube
```bash
minikube stop
minikube start
```
### Prevention
Add security context to your deployments to limit resource usage:
```yaml
securityContext:
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
```
## RouteBuilder TypeError Fix
### Symptoms
```
TypeError: RouteBuilder.build_resource_detail_route() takes from 2 to 4 positional arguments but 5 were given
```
### Root Cause
Incorrect usage of RouteBuilder methods. The `build_resource_detail_route` method only accepts 2-3 parameters, but was being called with 4-5 parameters.
### Solution
Use the correct RouteBuilder methods:
- **For nested resources**: Use `build_nested_resource_route()`
```python
# Wrong
route_builder.build_resource_detail_route("forecasts", "forecast_id", "feedback")
# Correct
route_builder.build_nested_resource_route("forecasts", "forecast_id", "feedback")
```
- **For resource actions**: Use `build_resource_action_route()`
```python
# Wrong
route_builder.build_resource_detail_route("forecasts", "forecast_id", "feedback", "retrain")
# Correct
route_builder.build_resource_action_route("forecasts", "forecast_id", "retrain")
```
### Files Fixed
- `services/forecasting/app/api/forecast_feedback.py`
## General Kubernetes Troubleshooting
### Check Pod Status
```bash
kubectl get pods -n bakery-ia
kubectl describe pod distribution-service -n bakery-ia
```
### Check Logs
```bash
kubectl logs distribution-service -n bakery-ia
kubectl logs -f distribution-service -n bakery-ia # Follow logs
```
### Check Resource Usage
```bash
kubectl top pods -n bakery-ia
kubectl describe nodes | grep -A 10 "Allocated resources"
```
### Restart Deployment
```bash
kubectl rollout restart deployment distribution-service -n bakery-ia
```
### Scale Down/Up
```bash
kubectl scale deployment distribution-service -n bakery-ia --replicas=1
kubectl scale deployment distribution-service -n bakery-ia --replicas=2
```
## Running Fix Scripts
### Fix Inotify Limits
```bash
cd scripts
./fix_kubernetes_inotify.sh
```
### Fix RouteBuilder Issues
The RouteBuilder issues have been fixed in the codebase. If you encounter similar issues:
1. Check the RouteBuilder method signatures in `shared/routing/route_builder.py`
2. Use the appropriate method for your routing pattern
3. Follow the examples in the fixed forecast feedback API
## Additional Resources
- [Kubernetes Inotify Limits](https://kind.sigs.k8s.io/docs/user/known-issues/#pod-errors-due-to-too-many-open-files)
- [Docker Desktop Resource Limits](https://docs.docker.com/desktop/settings/mac/#resources)
- [RouteBuilder Documentation](shared/routing/route_builder.py)
## Support
If issues persist after trying these solutions:
1. Check the specific error message and logs
2. Verify system resources (CPU, memory, disk)
3. Review recent changes to the codebase
4. Consult the architecture documentation for service boundaries

View File

@@ -0,0 +1,582 @@
/*
* Distribution Tab Component for Enterprise Dashboard
* Shows network-wide distribution status, route optimization, and delivery monitoring
*/
import React, { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Button } from '../ui/Button';
import { Truck, AlertTriangle, CheckCircle2, Activity, Timer, Map, Route, Package, Clock, Bell, Calendar } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDistributionOverview } from '../../api/hooks/useEnterpriseDashboard';
import { useSSEEvents } from '../../hooks/useSSE';
import StatusCard from '../ui/StatusCard/StatusCard';
interface DistributionTabProps {
tenantId: string;
selectedDate: string;
onDateChange: (date: string) => void;
}
const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDate, onDateChange }) => {
const { t } = useTranslation('dashboard');
// Get distribution data
const {
data: distributionOverview,
isLoading: isDistributionLoading,
error: distributionError
} = useDistributionOverview(tenantId, selectedDate, {
refetchInterval: 60000, // Refetch every minute
enabled: !!tenantId,
});
// Real-time SSE events
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// State for real-time delivery status
const [deliveryStatus, setDeliveryStatus] = useState({
total: 0,
onTime: 0,
delayed: 0,
inTransit: 0,
completed: 0
});
// State for route optimization metrics
const [optimizationMetrics, setOptimizationMetrics] = useState({
distanceSaved: 0,
timeSaved: 0,
fuelSaved: 0,
co2Saved: 0
});
// State for real-time events
const [recentDeliveryEvents, setRecentDeliveryEvents] = useState<any[]>([]);
// Process SSE events for distribution updates
useEffect(() => {
if (sseEvents.length === 0) return;
// Filter delivery and distribution-related events
const deliveryEvents = sseEvents.filter(event =>
event.event_type.includes('delivery_') ||
event.event_type.includes('route_') ||
event.event_type.includes('shipment_') ||
event.entity_type === 'delivery' ||
event.entity_type === 'shipment'
);
if (deliveryEvents.length === 0) return;
// Update delivery status based on events
let newStatus = { ...deliveryStatus };
let newMetrics = { ...optimizationMetrics };
deliveryEvents.forEach(event => {
switch (event.event_type) {
case 'delivery_completed':
newStatus.completed += 1;
newStatus.inTransit = Math.max(0, newStatus.inTransit - 1);
break;
case 'delivery_started':
case 'delivery_in_transit':
newStatus.inTransit += 1;
break;
case 'delivery_delayed':
newStatus.delayed += 1;
break;
case 'route_optimized':
if (event.event_metadata?.distance_saved) {
newMetrics.distanceSaved += event.event_metadata.distance_saved;
}
if (event.event_metadata?.time_saved) {
newMetrics.timeSaved += event.event_metadata.time_saved;
}
if (event.event_metadata?.fuel_saved) {
newMetrics.fuelSaved += event.event_metadata.fuel_saved;
}
break;
}
});
setDeliveryStatus(newStatus);
setOptimizationMetrics(newMetrics);
setRecentDeliveryEvents(deliveryEvents.slice(0, 5));
}, [sseEvents]);
// Initialize status from API data
useEffect(() => {
if (distributionOverview) {
const statusCounts = distributionOverview.status_counts || {};
setDeliveryStatus({
total: Object.values(statusCounts).reduce((sum, count) => sum + count, 0),
onTime: statusCounts['delivered'] || 0,
delayed: statusCounts['overdue'] || 0,
inTransit: (statusCounts['in_transit'] || 0) + (statusCounts['pending'] || 0),
completed: statusCounts['delivered'] || 0
});
}
}, [distributionOverview]);
const isLoading = isDistributionLoading;
// Mock route data - in Phase 2 this will come from real API
const mockRoutes = [
{
id: 'route-1',
name: 'Madrid → Barcelona',
status: 'in_transit',
distance: '620 km',
duration: '6h 30m',
stops: 3,
optimizationSavings: '12 km (1.9%)',
vehicles: ['TRUCK-001', 'TRUCK-002']
},
{
id: 'route-2',
name: 'Barcelona → Valencia',
status: 'completed',
distance: '350 km',
duration: '4h 15m',
stops: 2,
optimizationSavings: '8 km (2.3%)',
vehicles: ['VAN-005']
},
{
id: 'route-3',
name: 'Central → Outlets (Daily)',
status: 'pending',
distance: '180 km',
duration: '3h 00m',
stops: 5,
optimizationSavings: '25 km (13.9%)',
vehicles: ['TRUCK-003', 'VAN-006', 'VAN-007']
}
];
return (
<div className="space-y-8">
{/* Distribution Summary */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Truck className="w-6 h-6 text-[var(--color-primary)]" />
{t('enterprise.distribution_summary')}
</h2>
{/* Date selector */}
<div className="mb-4 flex items-center gap-4">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-[var(--text-secondary)]" />
<input
type="date"
value={selectedDate}
onChange={(e) => onDateChange(e.target.value)}
className="border border-[var(--border-primary)] rounded-md px-3 py-2 text-sm bg-[var(--input-bg)] text-[var(--text-primary)]"
/>
</div>
{sseConnected && (
<div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
<span className="w-2 h-2 rounded-full bg-[var(--color-success)] animate-pulse"></span>
{t('enterprise.live_updates')}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Total Deliveries */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.total_deliveries')}
</CardTitle>
<Package className="w-5 h-5 text-[var(--color-primary)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--text-primary)]">
{deliveryStatus.total}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.all_shipments')}
</p>
</CardContent>
</Card>
{/* On-time Deliveries */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.on_time_deliveries')}
</CardTitle>
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{deliveryStatus.onTime}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{deliveryStatus.total > 0
? `${Math.round((deliveryStatus.onTime / deliveryStatus.total) * 100)}% ${t('enterprise.on_time_rate')}`
: t('enterprise.no_deliveries')}
</p>
</CardContent>
</Card>
{/* Delayed Deliveries */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.delayed_deliveries')}
</CardTitle>
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-warning)]">
{deliveryStatus.delayed}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{deliveryStatus.total > 0
? `${Math.round((deliveryStatus.delayed / deliveryStatus.total) * 100)}% ${t('enterprise.delay_rate')}`
: t('enterprise.no_delays')}
</p>
</CardContent>
</Card>
{/* In Transit */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.in_transit')}
</CardTitle>
<Activity className="w-5 h-5 text-[var(--color-info)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-info)]">
{deliveryStatus.inTransit}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.currently_en_route')}
</p>
</CardContent>
</Card>
</div>
</div>
{/* Route Optimization Metrics */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Route className="w-6 h-6 text-[var(--color-success)]" />
{t('enterprise.route_optimization')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Distance Saved */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.distance_saved')}
</CardTitle>
<Map className="w-5 h-5 text-[var(--color-success)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{optimizationMetrics.distanceSaved} km
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.total_distance_saved')}
</p>
</CardContent>
</Card>
{/* Time Saved */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.time_saved')}
</CardTitle>
<Timer className="w-5 h-5 text-[var(--color-primary)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-primary)]">
{optimizationMetrics.timeSaved} min
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.total_time_saved')}
</p>
</CardContent>
</Card>
{/* Fuel Saved */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.fuel_saved')}
</CardTitle>
<Package className="w-5 h-5 text-[var(--color-info)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-info)]">
{optimizationMetrics.fuelSaved.toFixed(2)}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.estimated_fuel_savings')}
</p>
</CardContent>
</Card>
{/* CO2 Saved */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.co2_saved')}
</CardTitle>
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{optimizationMetrics.co2Saved} kg
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.estimated_co2_reduction')}
</p>
</CardContent>
</Card>
</div>
</div>
{/* Active Routes */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Route className="w-6 h-6 text-[var(--color-primary)]" />
{t('enterprise.active_routes')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{mockRoutes.map((route) => {
// Determine status configuration
const getStatusConfig = () => {
switch (route.status) {
case 'completed':
return {
color: '#10b981', // emerald-500
text: t('enterprise.route_completed'),
icon: CheckCircle2
};
case 'delayed':
case 'overdue':
return {
color: '#ef4444', // red-500
text: t('enterprise.route_delayed'),
icon: AlertTriangle,
isCritical: true
};
case 'in_transit':
return {
color: '#3b82f6', // blue-500
text: t('enterprise.route_in_transit'),
icon: Activity,
isHighlight: true
};
default: // pending, planned
return {
color: '#f59e0b', // amber-500
text: t('enterprise.route_pending'),
icon: Clock
};
}
};
const statusConfig = getStatusConfig();
return (
<StatusCard
key={route.id}
id={route.id}
statusIndicator={statusConfig}
title={route.name}
subtitle={`${t('enterprise.distance')}: ${route.distance}`}
primaryValue={route.duration}
primaryValueLabel={t('enterprise.estimated_duration')}
secondaryInfo={{
label: t('enterprise.stops'),
value: `${route.stops}`
}}
progress={{
label: t('enterprise.optimization'),
percentage: route.status === 'completed' ? 100 :
route.status === 'in_transit' ? 75 :
route.status === 'delayed' ? 50 : 25,
color: statusConfig.color
}}
metadata={[
`${t('enterprise.optimization_savings')}: ${route.optimizationSavings}`,
`${t('enterprise.vehicles')}: ${route.vehicles.join(', ')}`
]}
actions={[
{
label: t('enterprise.track_route'),
icon: Map,
variant: 'outline',
onClick: () => {
// In Phase 2, this will navigate to route tracking page
console.log(`Track route ${route.name}`);
},
priority: 'primary'
}
]}
onClick={() => {
// In Phase 2, this will navigate to route detail page
console.log(`View route ${route.name}`);
}}
/>
);
})}
</div>
</div>
{/* Real-time Delivery Events */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Bell className="w-6 h-6 text-[var(--color-info)]" />
{t('enterprise.real_time_delivery_events')}
</h2>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.recent_delivery_activity')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
{recentDeliveryEvents.length > 0 ? (
<div className="space-y-4">
{recentDeliveryEvents.map((event, index) => {
// Determine event icon and color based on type
const getEventConfig = () => {
switch (event.event_type) {
case 'delivery_delayed':
case 'delivery_overdue':
return { icon: AlertTriangle, color: 'text-[var(--color-warning)]' };
case 'delivery_completed':
case 'delivery_received':
return { icon: CheckCircle2, color: 'text-[var(--color-success)]' };
case 'delivery_started':
case 'delivery_in_transit':
return { icon: Activity, color: 'text-[var(--color-info)]' };
case 'route_optimized':
return { icon: Route, color: 'text-[var(--color-primary)]' };
default:
return { icon: Bell, color: 'text-[var(--color-secondary)]' };
}
};
const { icon: EventIcon, color } = getEventConfig();
const eventTime = new Date(event.timestamp || event.created_at || Date.now());
return (
<div key={index} className="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-primary)]">
<div className={`p-2 rounded-lg ${color.replace('text', 'bg')}`}>
<EventIcon className={`w-5 h-5 ${color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-[var(--text-primary)]">
{event.event_type.replace(/_/g, ' ')}
</p>
<p className="text-xs text-[var(--text-tertiary)] whitespace-nowrap">
{eventTime.toLocaleTimeString()}
</p>
</div>
{event.message && (
<p className="text-sm text-[var(--text-secondary)] mt-1">
{event.message}
</p>
)}
{event.entity_type && event.entity_id && (
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{event.entity_type}: {event.entity_id}
</p>
)}
{event.event_metadata?.route_name && (
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{t('enterprise.route')}: {event.event_metadata.route_name}
</p>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
{sseConnected ? t('enterprise.no_recent_delivery_activity') : t('enterprise.waiting_for_updates')}
</div>
)}
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Timer className="w-6 h-6 text-[var(--color-info)]" />
{t('enterprise.quick_actions')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Route className="w-6 h-6 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.optimize_routes')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('enterprise.optimize_routes_description')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/distribution/routes/optimize`}
className="w-full"
>
{t('enterprise.run_optimization')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Truck className="w-6 h-6 text-[var(--color-success)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.manage_vehicles')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('enterprise.manage_vehicle_fleet')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/distribution/vehicles`}
variant="outline"
className="w-full"
>
{t('enterprise.view_vehicles')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Map className="w-6 h-6 text-[var(--color-info)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.live_tracking')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('enterprise.real_time_gps_tracking')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/distribution/tracking`}
variant="outline"
className="w-full"
>
{t('enterprise.open_tracking_map')}
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
export default DistributionTab;

View File

@@ -0,0 +1,340 @@
/*
* Network Overview Tab Component for Enterprise Dashboard
* Shows network-wide status and critical alerts
*/
import React, { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Button } from '../ui/Button';
import { Network, AlertTriangle, CheckCircle2, Activity, TrendingUp, Bell, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import SystemStatusBlock from './blocks/SystemStatusBlock';
import NetworkSummaryCards from './NetworkSummaryCards';
import { useControlPanelData } from '../../api/hooks/useControlPanelData';
import { useNetworkSummary } from '../../api/hooks/useEnterpriseDashboard';
import { useSSEEvents } from '../../hooks/useSSE';
interface NetworkOverviewTabProps {
tenantId: string;
onOutletClick?: (outletId: string, outletName: string) => void;
}
const NetworkOverviewTab: React.FC<NetworkOverviewTabProps> = ({ tenantId, onOutletClick }) => {
const { t } = useTranslation('dashboard');
// Get network-wide control panel data (for system status)
const { data: controlPanelData, isLoading: isControlPanelLoading } = useControlPanelData(tenantId);
// Get network summary data
const { data: networkSummary, isLoading: isNetworkSummaryLoading } = useNetworkSummary(tenantId);
// Real-time SSE events
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// State for real-time notifications
const [recentEvents, setRecentEvents] = useState<any[]>([]);
const [showAllEvents, setShowAllEvents] = useState(false);
// Process SSE events for real-time notifications
useEffect(() => {
if (sseEvents.length === 0) return;
// Filter relevant events for network overview
const relevantEventTypes = [
'network_alert', 'outlet_performance_update', 'distribution_route_update',
'batch_completed', 'batch_started', 'delivery_received', 'delivery_overdue',
'equipment_maintenance', 'production_delay', 'stock_receipt_incomplete'
];
const networkEvents = sseEvents.filter(event =>
relevantEventTypes.includes(event.event_type)
);
// Keep only the 5 most recent events
setRecentEvents(networkEvents.slice(0, 5));
}, [sseEvents]);
const isLoading = isControlPanelLoading || isNetworkSummaryLoading;
return (
<div className="space-y-8">
{/* Network Status Block - Reusing SystemStatusBlock with network-wide data */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Network className="w-6 h-6 text-[var(--color-primary)]" />
{t('enterprise.network_status')}
</h2>
<SystemStatusBlock
data={controlPanelData}
loading={isControlPanelLoading}
/>
</div>
{/* Network Summary Cards */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Activity className="w-6 h-6 text-[var(--color-success)]" />
{t('enterprise.network_summary')}
</h2>
<NetworkSummaryCards
data={networkSummary}
isLoading={isNetworkSummaryLoading}
/>
</div>
{/* Quick Actions */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<TrendingUp className="w-6 h-6 text-[var(--color-info)]" />
{t('enterprise.quick_actions')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Network className="w-6 h-6 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.add_outlet')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('enterprise.add_outlet_description')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/settings/organization`}
className="w-full"
>
{t('enterprise.create_outlet')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<CheckCircle2 className="w-6 h-6 text-[var(--color-success)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.internal_transfers')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('enterprise.manage_transfers')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/procurement/internal-transfers`}
variant="outline"
className="w-full"
>
{t('enterprise.view_transfers')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-[var(--color-warning)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.view_alerts')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('enterprise.network_alerts_description')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/alerts`}
variant="outline"
className="w-full"
>
{t('enterprise.view_all_alerts')}
</Button>
</CardContent>
</Card>
</div>
</div>
{/* Network Health Indicators */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<CheckCircle2 className="w-6 h-6 text-[var(--color-success)]" />
{t('enterprise.network_health')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* On-time Delivery Rate */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.on_time_delivery')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{controlPanelData?.orchestrationSummary?.aiHandlingRate || 0}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.delivery_performance')}
</p>
</CardContent>
</Card>
{/* Issue Prevention Rate */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.issue_prevention')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-primary)]">
{controlPanelData?.issuesPreventedByAI || 0}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.issues_prevented')}
</p>
</CardContent>
</Card>
{/* Active Issues */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.active_issues')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-warning)]">
{controlPanelData?.issuesRequiringAction || 0}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.action_required')}
</p>
</CardContent>
</Card>
{/* Network Efficiency */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.network_efficiency')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-info)]">
{Math.round((controlPanelData?.issuesPreventedByAI || 0) /
Math.max(1, (controlPanelData?.issuesPreventedByAI || 0) + (controlPanelData?.issuesRequiringAction || 0)) * 100) || 0}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.operational_efficiency')}
</p>
</CardContent>
</Card>
</div>
</div>
{/* Real-time Events Notification */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Bell className="w-6 h-6 text-[var(--color-info)]" />
{t('enterprise.real_time_events')}
</h2>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.recent_activity')}
</CardTitle>
{sseConnected ? (
<div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
<span className="w-2 h-2 rounded-full bg-[var(--color-success)] animate-pulse"></span>
{t('enterprise.live_updates')}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-[var(--color-warning)]">
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)]"></span>
{t('enterprise.offline')}
</div>
)}
</div>
</CardHeader>
<CardContent>
{recentEvents.length > 0 ? (
<div className="space-y-4">
{recentEvents.slice(0, showAllEvents ? recentEvents.length : 3).map((event, index) => {
// Determine event icon and color based on type
const getEventConfig = () => {
switch (event.event_type) {
case 'network_alert':
case 'production_delay':
case 'equipment_maintenance':
return { icon: AlertTriangle, color: 'text-[var(--color-warning)]' };
case 'batch_completed':
case 'delivery_received':
return { icon: CheckCircle2, color: 'text-[var(--color-success)]' };
case 'batch_started':
case 'outlet_performance_update':
return { icon: Activity, color: 'text-[var(--color-info)]' };
case 'delivery_overdue':
case 'stock_receipt_incomplete':
return { icon: Clock, color: 'text-[var(--color-danger)]' };
default:
return { icon: Bell, color: 'text-[var(--color-primary)]' };
}
};
const { icon: EventIcon, color } = getEventConfig();
const eventTime = new Date(event.timestamp || event.created_at || Date.now());
return (
<div key={index} className="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-primary)]">
<div className={`p-2 rounded-lg ${color.replace('text', 'bg')}`}>
<EventIcon className={`w-5 h-5 ${color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-[var(--text-primary)]">
{event.event_type.replace(/_/g, ' ')}
</p>
<p className="text-xs text-[var(--text-tertiary)] whitespace-nowrap">
{eventTime.toLocaleTimeString()}
</p>
</div>
{event.message && (
<p className="text-sm text-[var(--text-secondary)] mt-1">
{event.message}
</p>
)}
{event.entity_type && event.entity_id && (
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{event.entity_type}: {event.entity_id}
</p>
)}
</div>
</div>
);
})}
{recentEvents.length > 3 && !showAllEvents && (
<Button
onClick={() => setShowAllEvents(true)}
variant="outline"
size="sm"
className="w-full mt-2"
>
{t('enterprise.show_all_events', { count: recentEvents.length })}
</Button>
)}
{showAllEvents && recentEvents.length > 3 && (
<Button
onClick={() => setShowAllEvents(false)}
variant="outline"
size="sm"
className="w-full mt-2"
>
{t('enterprise.show_less')}
</Button>
)}
</div>
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
{sseConnected ? t('enterprise.no_recent_activity') : t('enterprise.waiting_for_updates')}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
};
export default NetworkOverviewTab;

View File

@@ -0,0 +1,531 @@
/*
* Network Performance Tab Component for Enterprise Dashboard
* Shows cross-location benchmarking and performance comparison
*/
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Button } from '../ui/Button';
import { BarChart3, TrendingUp, TrendingDown, Activity, CheckCircle2, AlertTriangle, Clock, Award, Target, LineChart, PieChart, Building2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useChildrenPerformance } from '../../api/hooks/useEnterpriseDashboard';
import PerformanceChart from '../charts/PerformanceChart';
import StatusCard from '../ui/StatusCard/StatusCard';
interface NetworkPerformanceTabProps {
tenantId: string;
onOutletClick?: (outletId: string, outletName: string) => void;
}
const NetworkPerformanceTab: React.FC<NetworkPerformanceTabProps> = ({ tenantId, onOutletClick }) => {
const { t } = useTranslation('dashboard');
const [selectedMetric, setSelectedMetric] = useState('sales');
const [selectedPeriod, setSelectedPeriod] = useState(30);
const [viewMode, setViewMode] = useState<'chart' | 'cards'>('chart');
// Get children performance data
const {
data: childrenPerformance,
isLoading: isChildrenPerformanceLoading,
error: childrenPerformanceError
} = useChildrenPerformance(tenantId!, selectedMetric, selectedPeriod, {
enabled: !!tenantId,
});
const isLoading = isChildrenPerformanceLoading;
// Calculate network-wide metrics
const calculateNetworkMetrics = () => {
if (!childrenPerformance?.rankings || childrenPerformance.rankings.length === 0) {
return null;
}
const rankings = childrenPerformance.rankings;
// Calculate averages
const totalSales = rankings.reduce((sum, r) => sum + (selectedMetric === 'sales' ? r.metric_value : 0), 0);
const totalInventory = rankings.reduce((sum, r) => sum + (selectedMetric === 'inventory_value' ? r.metric_value : 0), 0);
const totalOrders = rankings.reduce((sum, r) => sum + (selectedMetric === 'order_frequency' ? r.metric_value : 0), 0);
const avgSales = totalSales / rankings.length;
const avgInventory = totalInventory / rankings.length;
const avgOrders = totalOrders / rankings.length;
// Find top and bottom performers
const sortedByMetric = [...rankings].sort((a, b) => b.metric_value - a.metric_value);
const topPerformer = sortedByMetric[0];
const bottomPerformer = sortedByMetric[sortedByMetric.length - 1];
// Calculate performance variance
const variance = sortedByMetric.length > 1
? Math.round(((topPerformer.metric_value - bottomPerformer.metric_value) / topPerformer.metric_value) * 100)
: 0;
return {
totalOutlets: rankings.length,
avgSales,
avgInventory,
avgOrders,
totalSales,
totalInventory,
totalOrders,
topPerformer,
bottomPerformer,
variance,
networkEfficiency: Math.min(95, 85 + (100 - variance) / 2) // Cap at 95%
};
};
const networkMetrics = calculateNetworkMetrics();
// Get performance trend indicators
const getPerformanceIndicator = (outletId: string) => {
if (!childrenPerformance?.rankings) return null;
const outlet = childrenPerformance.rankings.find(r => r.outlet_id === outletId);
if (!outlet) return null;
// Simple trend calculation based on position
const position = childrenPerformance.rankings.findIndex(r => r.outlet_id === outletId) + 1;
const total = childrenPerformance.rankings.length;
if (position <= Math.ceil(total * 0.3)) {
return { icon: TrendingUp, color: '#10b981', trend: 'improving' };
} else if (position >= Math.floor(total * 0.7)) {
return { icon: TrendingDown, color: '#ef4444', trend: 'declining' };
} else {
return { icon: Activity, color: '#f59e0b', trend: 'stable' };
}
};
return (
<div className="space-y-8">
{/* Performance Header */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<BarChart3 className="w-6 h-6 text-[var(--color-primary)]" />
{t('enterprise.network_performance')}
</h2>
<p className="text-[var(--text-secondary)] mb-6">
{t('enterprise.performance_description')}
</p>
{/* Metric and Period Selectors */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex items-center gap-2">
<select
value={selectedMetric}
onChange={(e) => setSelectedMetric(e.target.value)}
className="border border-[var(--border-primary)] rounded-md px-3 py-2 text-sm bg-[var(--input-bg)] text-[var(--text-primary)]"
>
<option value="sales">{t('enterprise.metrics.sales')}</option>
<option value="inventory_value">{t('enterprise.metrics.inventory_value')}</option>
<option value="order_frequency">{t('enterprise.metrics.order_frequency')}</option>
<option value="on_time_delivery">{t('enterprise.metrics.on_time_delivery')}</option>
<option value="inventory_turnover">{t('enterprise.metrics.inventory_turnover')}</option>
</select>
</div>
<div className="flex items-center gap-2">
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(Number(e.target.value))}
className="border border-[var(--border-primary)] rounded-md px-3 py-2 text-sm bg-[var(--input-bg)] text-[var(--text-primary)]"
>
<option value={7}>{t('enterprise.last_7_days')}</option>
<option value={30}>{t('enterprise.last_30_days')}</option>
<option value={90}>{t('enterprise.last_90_days')}</option>
<option value={180}>{t('enterprise.last_6_months')}</option>
</select>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant={viewMode === 'chart' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('chart')}
>
<LineChart className="w-4 h-4 mr-2" />
{t('enterprise.chart_view')}
</Button>
<Button
variant={viewMode === 'cards' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('cards')}
>
<PieChart className="w-4 h-4 mr-2" />
{t('enterprise.card_view')}
</Button>
</div>
</div>
</div>
{/* Network Performance Summary */}
{networkMetrics && (
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Target className="w-5 h-5 text-[var(--color-info)]" />
{t('enterprise.network_summary')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Network Efficiency */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.network_efficiency')}
</CardTitle>
<Activity className="w-5 h-5 text-[var(--color-primary)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-primary)]">
{networkMetrics.networkEfficiency}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.operational_efficiency')}
</p>
</CardContent>
</Card>
{/* Performance Variance */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.performance_variance')}
</CardTitle>
<BarChart3 className="w-5 h-5 text-[var(--color-warning)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-warning)]">
{networkMetrics.variance}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.top_to_bottom_spread')}
</p>
</CardContent>
</Card>
{/* Average Performance */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{selectedMetric === 'sales' ? t('enterprise.avg_sales') :
selectedMetric === 'inventory_value' ? t('enterprise.avg_inventory') :
t('enterprise.avg_orders')}
</CardTitle>
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{selectedMetric === 'sales' ? `${networkMetrics.avgSales.toLocaleString()}` :
selectedMetric === 'inventory_value' ? `${networkMetrics.avgInventory.toLocaleString()}` :
networkMetrics.avgOrders.toLocaleString()}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.per_outlet')}
</p>
</CardContent>
</Card>
{/* Total Outlets */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.total_outlets')}
</CardTitle>
<Building2 className="w-5 h-5 text-[var(--color-info)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-info)]">
{networkMetrics.totalOutlets}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.locations_in_network')}
</p>
</CardContent>
</Card>
</div>
</div>
)}
{/* Performance Insights */}
{networkMetrics && (
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Award className="w-5 h-5 text-[var(--color-success)]" />
{t('enterprise.performance_insights')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Top Performer */}
<StatusCard
id={`top-performer`}
statusIndicator={{
color: '#10b981',
text: t('enterprise.top_performer'),
icon: Award,
isHighlight: true
}}
title={networkMetrics.topPerformer.outlet_name}
subtitle={t('enterprise.best_in_network')}
primaryValue={selectedMetric === 'sales' ? `${networkMetrics.topPerformer.metric_value.toLocaleString()}` :
selectedMetric === 'inventory_value' ? `${networkMetrics.topPerformer.metric_value.toLocaleString()}` :
networkMetrics.topPerformer.metric_value.toLocaleString()}
primaryValueLabel={selectedMetric === 'sales' ? t('enterprise.sales') :
selectedMetric === 'inventory_value' ? t('enterprise.inventory_value') :
t('enterprise.orders')}
secondaryInfo={{
label: t('enterprise.performance_index'),
value: `${Math.round((networkMetrics.topPerformer.metric_value / networkMetrics.avgSales) * 100)}%`
}}
progress={{
label: t('enterprise.above_average'),
percentage: Math.min(100, Math.round(((networkMetrics.topPerformer.metric_value - networkMetrics.avgSales) / networkMetrics.avgSales) * 100)),
color: '#10b981'
}}
metadata={[
`${t('enterprise.location')}: ${networkMetrics.topPerformer.outlet_name}`,
`${t('enterprise.rank')}: #1`
]}
actions={onOutletClick ? [{
label: t('enterprise.view_details'),
icon: BarChart3,
variant: 'outline',
onClick: () => onOutletClick(networkMetrics.topPerformer.outlet_id, networkMetrics.topPerformer.outlet_name),
priority: 'primary'
}] : []}
/>
{/* Bottom Performer */}
<StatusCard
id={`bottom-performer`}
statusIndicator={{
color: '#ef4444',
text: t('enterprise.needs_attention'),
icon: AlertTriangle,
isCritical: true
}}
title={networkMetrics.bottomPerformer.outlet_name}
subtitle={t('enterprise.improvement_opportunity')}
primaryValue={selectedMetric === 'sales' ? `${networkMetrics.bottomPerformer.metric_value.toLocaleString()}` :
selectedMetric === 'inventory_value' ? `${networkMetrics.bottomPerformer.metric_value.toLocaleString()}` :
networkMetrics.bottomPerformer.metric_value.toLocaleString()}
primaryValueLabel={selectedMetric === 'sales' ? t('enterprise.sales') :
selectedMetric === 'inventory_value' ? t('enterprise.inventory_value') :
t('enterprise.orders')}
secondaryInfo={{
label: t('enterprise.performance_index'),
value: `${Math.round((networkMetrics.bottomPerformer.metric_value / networkMetrics.avgSales) * 100)}%`
}}
progress={{
label: t('enterprise.below_average'),
percentage: Math.round(((networkMetrics.avgSales - networkMetrics.bottomPerformer.metric_value) / networkMetrics.avgSales) * 100),
color: '#ef4444'
}}
metadata={[
`${t('enterprise.location')}: ${networkMetrics.bottomPerformer.outlet_name}`,
`${t('enterprise.rank')}: #${networkMetrics.totalOutlets}`
]}
actions={onOutletClick ? [{
label: t('enterprise.view_details'),
icon: BarChart3,
variant: 'outline',
onClick: () => onOutletClick(networkMetrics.bottomPerformer.outlet_id, networkMetrics.bottomPerformer.outlet_name),
priority: 'primary'
}] : []}
/>
{/* Network Insight */}
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Award className="w-6 h-6 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.network_insight')}</h3>
</div>
<div className="space-y-3">
{networkMetrics.variance < 20 ? (
<div className="flex items-center gap-2 text-[var(--color-success)]">
<CheckCircle2 className="w-5 h-5" />
<span className="text-sm">{t('enterprise.highly_balanced_network')}</span>
</div>
) : networkMetrics.variance < 40 ? (
<div className="flex items-center gap-2 text-[var(--color-warning)]">
<AlertTriangle className="w-5 h-5" />
<span className="text-sm">{t('enterprise.moderate_variation')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-[var(--color-danger)]">
<AlertTriangle className="w-5 h-5" />
<span className="text-sm">{t('enterprise.high_variation')}</span>
</div>
)}
<p className="text-sm text-[var(--text-secondary)] mt-3">
{networkMetrics.variance < 20
? t('enterprise.balanced_network_description')
: networkMetrics.variance < 40
? t('enterprise.moderate_variation_description')
: t('enterprise.high_variation_description')}
</p>
{networkMetrics.variance >= 40 && (
<Button
onClick={() => setSelectedMetric('sales')}
variant="outline"
size="sm"
className="mt-3"
>
{t('enterprise.analyze_performance')}
</Button>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Main Performance Visualization */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[var(--color-primary)]" />
{t('enterprise.outlet_comparison')}
</h3>
{viewMode === 'chart' ? (
<Card className="h-full">
<CardContent className="p-6">
{childrenPerformance && childrenPerformance.rankings ? (
<PerformanceChart
data={childrenPerformance.rankings}
metric={selectedMetric}
period={selectedPeriod}
onOutletClick={onOutletClick}
showTrendIndicators={true}
/>
) : (
<div className="h-96 flex items-center justify-center text-[var(--text-secondary)]">
{isLoading ? t('enterprise.loading_performance') : t('enterprise.no_performance_data')}
</div>
)}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{childrenPerformance?.rankings?.map((outlet, index) => {
const performanceIndicator = getPerformanceIndicator(outlet.outlet_id);
return (
<StatusCard
key={outlet.outlet_id}
id={outlet.outlet_id}
statusIndicator={performanceIndicator ? {
color: performanceIndicator.color,
text: performanceIndicator.trend === 'improving' ? t('enterprise.improving') :
performanceIndicator.trend === 'declining' ? t('enterprise.declining') : t('enterprise.stable'),
icon: performanceIndicator.icon
} : {
color: '#6b7280',
text: t('enterprise.no_data'),
icon: Clock
}}
title={outlet.outlet_name}
subtitle={`#${index + 1} ${t('enterprise.of')} ${childrenPerformance.rankings.length}`}
primaryValue={selectedMetric === 'sales' ? `${outlet.metric_value.toLocaleString()}` :
selectedMetric === 'inventory_value' ? `${outlet.metric_value.toLocaleString()}` :
outlet.metric_value.toLocaleString()}
primaryValueLabel={selectedMetric === 'sales' ? t('enterprise.sales') :
selectedMetric === 'inventory_value' ? t('enterprise.inventory_value') :
t('enterprise.orders')}
secondaryInfo={{
label: t('enterprise.performance_index'),
value: `${Math.round((outlet.metric_value / networkMetrics.avgSales) * 100)}%`
}}
progress={{
label: t('enterprise.of_network_avg'),
percentage: Math.round((outlet.metric_value / networkMetrics.avgSales) * 100),
color: performanceIndicator?.color || '#6b7280'
}}
metadata={[
`${t('enterprise.location_id')}: ${outlet.outlet_id}`,
`${t('enterprise.period')}: ${selectedPeriod} ${t('enterprise.days')}`
]}
actions={onOutletClick ? [{
label: t('enterprise.view_details'),
icon: BarChart3,
variant: 'outline',
onClick: () => onOutletClick(outlet.outlet_id, outlet.outlet_name),
priority: 'primary'
}] : []}
/>
);
})}
</div>
)}
</div>
{/* Performance Recommendations */}
{networkMetrics && networkMetrics.variance >= 30 && (
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[var(--color-success)]" />
{t('enterprise.performance_recommendations')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Award className="w-6 h-6 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.best_practices')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{t('enterprise.learn_from_top_performer', {
name: networkMetrics.topPerformer.outlet_name
})}
</p>
<Button variant="outline" size="sm">
{t('enterprise.schedule_knowledge_sharing')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-[var(--color-warning)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.targeted_improvement')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{t('enterprise.focus_on_bottom_performer', {
name: networkMetrics.bottomPerformer.outlet_name
})}
</p>
<Button variant="outline" size="sm">
{t('enterprise.create_improvement_plan')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Target className="w-6 h-6 text-[var(--color-success)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.network_goal')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{t('enterprise.reduce_variance_goal', {
current: networkMetrics.variance,
target: Math.max(10, networkMetrics.variance - 15)
})}
</p>
<Button variant="outline" size="sm">
{t('enterprise.set_network_targets')}
</Button>
</CardContent>
</Card>
</div>
</div>
)}
</div>
);
};
export default NetworkPerformanceTab;

View File

@@ -0,0 +1,603 @@
/*
* Outlet Fulfillment Tab Component for Enterprise Dashboard
* Shows outlet inventory coverage, stockout risk, and fulfillment status
*/
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Button } from '../ui/Button';
import { Package, AlertTriangle, CheckCircle2, Activity, Clock, Warehouse, ShoppingCart, Truck, BarChart3, AlertCircle, ShieldCheck, PackageCheck, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import StatusCard from '../ui/StatusCard/StatusCard';
import { useSSEEvents } from '../../hooks/useSSE';
interface OutletFulfillmentTabProps {
tenantId: string;
onOutletClick?: (outletId: string, outletName: string) => void;
}
const OutletFulfillmentTab: React.FC<OutletFulfillmentTabProps> = ({ tenantId, onOutletClick }) => {
const { t } = useTranslation('dashboard');
const [selectedOutlet, setSelectedOutlet] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'summary' | 'detailed'>('summary');
// Real-time SSE events
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// State for real-time inventory data
const [inventoryData, setInventoryData] = useState([
{
id: 'outlet-madrid',
name: 'Madrid Central',
inventoryCoverage: 85,
stockoutRisk: 'low',
criticalItems: 2,
fulfillmentRate: 98,
lastUpdated: '2024-01-15T10:30:00',
status: 'normal',
products: [
{ id: 'baguette', name: 'Baguette', coverage: 92, risk: 'low', stock: 450, safetyStock: 300 },
{ id: 'croissant', name: 'Croissant', coverage: 78, risk: 'medium', stock: 280, safetyStock: 250 },
{ id: 'pain-au-chocolat', name: 'Pain au Chocolat', coverage: 65, risk: 'high', stock: 180, safetyStock: 200 }
]
},
{
id: 'outlet-barcelona',
name: 'Barcelona Coastal',
inventoryCoverage: 68,
stockoutRisk: 'medium',
criticalItems: 5,
fulfillmentRate: 92,
lastUpdated: '2024-01-15T10:25:00',
status: 'warning',
products: [
{ id: 'baguette', name: 'Baguette', coverage: 75, risk: 'medium', stock: 320, safetyStock: 300 },
{ id: 'croissant', name: 'Croissant', coverage: 58, risk: 'high', stock: 220, safetyStock: 250 },
{ id: 'ensaimada', name: 'Ensaimada', coverage: 45, risk: 'critical', stock: 120, safetyStock: 200 }
]
},
{
id: 'outlet-valencia',
name: 'Valencia Port',
inventoryCoverage: 72,
stockoutRisk: 'medium',
criticalItems: 3,
fulfillmentRate: 95,
lastUpdated: '2024-01-15T10:20:00',
status: 'warning',
products: [
{ id: 'baguette', name: 'Baguette', coverage: 88, risk: 'low', stock: 420, safetyStock: 300 },
{ id: 'croissant', name: 'Croissant', coverage: 65, risk: 'medium', stock: 240, safetyStock: 250 },
{ id: 'focaccia', name: 'Focaccia', coverage: 55, risk: 'high', stock: 160, safetyStock: 200 }
]
}
]);
// Process SSE events for inventory updates
useEffect(() => {
if (sseEvents.length === 0) return;
// Filter inventory-related events
const inventoryEvents = sseEvents.filter(event =>
event.event_type.includes('inventory_') ||
event.event_type.includes('stock_') ||
event.event_type === 'stock_receipt_incomplete' ||
event.entity_type === 'inventory'
);
if (inventoryEvents.length === 0) return;
// Update inventory data based on events
setInventoryData(prevData => {
return prevData.map(outlet => {
// Find events for this outlet
const outletEvents = inventoryEvents.filter(event =>
event.entity_id === outlet.id ||
event.event_metadata?.outlet_id === outlet.id
);
if (outletEvents.length === 0) return outlet;
// Calculate new inventory coverage based on events
let newCoverage = outlet.inventoryCoverage;
let newRisk = outlet.stockoutRisk;
let newStatus = outlet.status;
let newCriticalItems = outlet.criticalItems;
outletEvents.forEach(event => {
switch (event.event_type) {
case 'inventory_low':
case 'stock_receipt_incomplete':
newCoverage = Math.max(0, newCoverage - 10);
if (newCoverage < 50) newRisk = 'high';
if (newCoverage < 30) newRisk = 'critical';
newStatus = 'critical';
newCriticalItems += 1;
break;
case 'inventory_replenished':
case 'stock_received':
newCoverage = Math.min(100, newCoverage + 15);
if (newCoverage > 70) newRisk = 'low';
if (newCoverage > 50) newRisk = 'medium';
newStatus = newCoverage > 80 ? 'normal' : 'warning';
newCriticalItems = Math.max(0, newCriticalItems - 1);
break;
case 'inventory_adjustment':
// Adjust coverage based on event metadata
if (event.event_metadata?.coverage_change) {
newCoverage = Math.min(100, Math.max(0, newCoverage + event.event_metadata.coverage_change));
}
break;
}
});
return {
...outlet,
inventoryCoverage: newCoverage,
stockoutRisk: newRisk,
status: newStatus,
criticalItems: newCriticalItems,
lastUpdated: new Date().toISOString()
};
});
});
}, [sseEvents]);
// Calculate network-wide fulfillment metrics
const calculateNetworkMetrics = () => {
const totalOutlets = inventoryData.length;
const avgCoverage = inventoryData.reduce((sum, outlet) => sum + outlet.inventoryCoverage, 0) / totalOutlets;
const avgFulfillment = inventoryData.reduce((sum, outlet) => sum + outlet.fulfillmentRate, 0) / totalOutlets;
const criticalOutlets = inventoryData.filter(outlet => outlet.status === 'critical').length;
const warningOutlets = inventoryData.filter(outlet => outlet.status === 'warning').length;
const normalOutlets = inventoryData.filter(outlet => outlet.status === 'normal').length;
const totalCriticalItems = inventoryData.reduce((sum, outlet) => sum + outlet.criticalItems, 0);
return {
totalOutlets,
avgCoverage,
avgFulfillment,
criticalOutlets,
warningOutlets,
normalOutlets,
totalCriticalItems,
networkHealth: Math.round(avgCoverage * 0.6 + avgFulfillment * 0.4)
};
};
const networkMetrics = calculateNetworkMetrics();
// Get status configuration for outlets
const getOutletStatusConfig = (outletId: string) => {
const outlet = inventoryData.find(o => o.id === outletId);
if (!outlet) return null;
switch (outlet.status) {
case 'critical':
return {
color: '#ef4444', // red-500
text: t('enterprise.status_critical'),
icon: AlertCircle,
isCritical: true
};
case 'warning':
return {
color: '#f59e0b', // amber-500
text: outlet.stockoutRisk === 'high' ? t('enterprise.high_stockout_risk') : t('enterprise.medium_stockout_risk'),
icon: AlertTriangle,
isHighlight: true
};
default:
return {
color: '#10b981', // emerald-500
text: t('enterprise.status_normal'),
icon: CheckCircle2
};
}
};
// Get risk level configuration
const getRiskConfig = (riskLevel: string) => {
switch (riskLevel) {
case 'critical':
return { color: '#ef4444', text: t('enterprise.risk_critical'), icon: AlertCircle };
case 'high':
return { color: '#f59e0b', text: t('enterprise.risk_high'), icon: AlertTriangle };
case 'medium':
return { color: '#fbbf24', text: t('enterprise.risk_medium'), icon: AlertTriangle };
default:
return { color: '#10b981', text: t('enterprise.risk_low'), icon: CheckCircle2 };
}
};
return (
<div className="space-y-8">
{/* Fulfillment Header */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Warehouse className="w-6 h-6 text-[var(--color-primary)]" />
{t('enterprise.outlet_fulfillment')}
</h2>
<p className="text-[var(--text-secondary)] mb-6">
{t('enterprise.fulfillment_description')}
</p>
{/* View Mode Selector */}
<div className="flex gap-2 mb-6">
<Button
variant={viewMode === 'summary' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('summary')}
>
<BarChart3 className="w-4 h-4 mr-2" />
{t('enterprise.summary_view')}
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('detailed')}
>
<PackageCheck className="w-4 h-4 mr-2" />
{t('enterprise.detailed_view')}
</Button>
</div>
</div>
{/* Network Fulfillment Summary */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-[var(--color-success)]" />
{t('enterprise.fulfillment_summary')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Network Health Score */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.network_health_score')}
</CardTitle>
<Activity className="w-5 h-5 text-[var(--color-primary)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-primary)]">
{networkMetrics.networkHealth}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.overall_fulfillment_health')}
</p>
</CardContent>
</Card>
{/* Average Inventory Coverage */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.avg_inventory_coverage')}
</CardTitle>
<Package className="w-5 h-5 text-[var(--color-success)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{networkMetrics.avgCoverage}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.across_all_outlets')}
</p>
</CardContent>
</Card>
{/* Fulfillment Rate */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.fulfillment_rate')}
</CardTitle>
<ShoppingCart className="w-5 h-5 text-[var(--color-info)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-info)]">
{networkMetrics.avgFulfillment}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.order_fulfillment_rate')}
</p>
</CardContent>
</Card>
{/* Critical Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.critical_items')}
</CardTitle>
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-warning)]">
{networkMetrics.totalCriticalItems}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.items_at_risk')}
</p>
</CardContent>
</Card>
</div>
</div>
{/* Outlet Status Overview */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Warehouse className="w-5 h-5 text-[var(--color-primary)]" />
{t('enterprise.outlet_status_overview')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{inventoryData.map((outlet) => {
const statusConfig = getOutletStatusConfig(outlet.id);
return (
<StatusCard
key={outlet.id}
id={outlet.id}
statusIndicator={statusConfig || {
color: '#6b7280',
text: t('enterprise.no_data'),
icon: Clock
}}
title={outlet.name}
subtitle={`${t('enterprise.inventory_coverage')}: ${outlet.inventoryCoverage}%`}
primaryValue={`${outlet.fulfillmentRate}%`}
primaryValueLabel={t('enterprise.fulfillment_rate')}
secondaryInfo={{
label: t('enterprise.critical_items'),
value: `${outlet.criticalItems}`
}}
progress={{
label: t('enterprise.inventory_coverage'),
percentage: outlet.inventoryCoverage,
color: statusConfig?.color || '#6b7280'
}}
metadata={[
`${t('enterprise.stockout_risk')}: ${t(`enterprise.risk_${outlet.stockoutRisk}`)}`,
`${t('enterprise.last_updated')}: ${new Date(outlet.lastUpdated).toLocaleTimeString()}`,
sseConnected ? `🟢 ${t('enterprise.live_updates')}` : `🟡 ${t('enterprise.offline')}`
]}
actions={onOutletClick ? [{
label: t('enterprise.view_details'),
icon: PackageCheck,
variant: 'outline',
onClick: () => {
setSelectedOutlet(outlet.id);
setViewMode('detailed');
onOutletClick(outlet.id, outlet.name);
},
priority: 'primary'
}] : []}
onClick={() => {
setSelectedOutlet(outlet.id);
setViewMode('detailed');
}}
/>
);
})}
</div>
</div>
{/* Detailed View - Product Level Inventory */}
{viewMode === 'detailed' && selectedOutlet && (
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<PackageCheck className="w-5 h-5 text-[var(--color-info)]" />
{t('enterprise.product_level_inventory')}
</h3>
<div className="mb-4">
<Button
onClick={() => setViewMode('summary')}
variant="outline"
size="sm"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('enterprise.back_to_summary')}
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{inventoryData
.find(outlet => outlet.id === selectedOutlet)
?.products.map((product) => {
const riskConfig = getRiskConfig(product.risk);
return (
<StatusCard
key={product.id}
id={product.id}
statusIndicator={riskConfig}
title={product.name}
subtitle={`${t('enterprise.current_stock')}: ${product.stock} units`}
primaryValue={`${product.coverage}%`}
primaryValueLabel={t('enterprise.inventory_coverage')}
secondaryInfo={{
label: t('enterprise.safety_stock'),
value: `${product.safetyStock} units`
}}
progress={{
label: t('enterprise.coverage_of_safety'),
percentage: Math.min(100, Math.round((product.stock / product.safetyStock) * 100)),
color: riskConfig.color
}}
metadata={[
`${t('enterprise.risk_level')}: ${riskConfig.text}`,
`${t('enterprise.stock_above_safety')}: ${product.stock >= product.safetyStock ? t('enterprise.yes') : t('enterprise.no')}`
]}
actions={[
{
label: t('enterprise.transfer_stock'),
icon: Truck,
variant: 'outline',
onClick: () => {
// In Phase 2, this will navigate to transfer page
console.log(`Transfer stock for ${product.name}`);
},
priority: 'primary'
}
]}
/>
);
})}
</div>
</div>
)}
{/* Fulfillment Recommendations */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
{t('enterprise.fulfillment_recommendations')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Critical Outlets */}
{networkMetrics.criticalOutlets > 0 && (
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<AlertCircle className="w-6 h-6 text-[var(--color-danger)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.critical_outlets')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{t('enterprise.critical_outlets_description', {
count: networkMetrics.criticalOutlets
})}
</p>
<Button variant="outline" size="sm">
{t('enterprise.prioritize_transfers')}
</Button>
</CardContent>
</Card>
)}
{/* Inventory Optimization */}
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Package className="w-6 h-6 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.inventory_optimization')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{networkMetrics.avgCoverage < 70
? t('enterprise.low_coverage_recommendation')
: t('enterprise.good_coverage_recommendation')}
</p>
<Button variant="outline" size="sm">
{t('enterprise.run_optimization')}
</Button>
</CardContent>
</Card>
{/* Fulfillment Excellence */}
{networkMetrics.avgFulfillment > 95 && (
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<ShieldCheck className="w-6 h-6 text-[var(--color-success)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.fulfillment_excellence')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{t('enterprise.high_fulfillment_congrats', {
rate: networkMetrics.avgFulfillment
})}
</p>
<Button variant="outline" size="sm">
{t('enterprise.maintain_excellence')}
</Button>
</CardContent>
</Card>
)}
</div>
</div>
{/* Real-time Inventory Alerts */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-[var(--color-info)]" />
{t('enterprise.real_time_inventory_alerts')}
</h3>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.recent_inventory_events')}
</CardTitle>
{sseConnected ? (
<div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
<span className="w-2 h-2 rounded-full bg-[var(--color-success)] animate-pulse"></span>
{t('enterprise.live_updates')}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-[var(--color-warning)]">
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)]"></span>
{t('enterprise.offline')}
</div>
)}
</div>
</CardHeader>
<CardContent>
{sseConnected ? (
<div className="space-y-4">
{inventoryData
.filter(outlet => outlet.status !== 'normal')
.map((outlet, index) => {
const statusConfig = getOutletStatusConfig(outlet.id);
const EventIcon = statusConfig?.icon || AlertTriangle;
const color = statusConfig?.color || 'text-[var(--color-warning)]';
return (
<div key={index} className="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-primary)]">
<div className={`p-2 rounded-lg ${color.replace('text', 'bg')}`}>
<EventIcon className={`w-5 h-5 ${color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-[var(--text-primary)]">
{outlet.name} - {statusConfig?.text}
</p>
<p className="text-xs text-[var(--text-tertiary)] whitespace-nowrap">
{new Date(outlet.lastUpdated).toLocaleTimeString()}
</p>
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{t('enterprise.inventory_coverage')}: {outlet.inventoryCoverage}% | {t('enterprise.critical_items')}: {outlet.criticalItems}
</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{t('enterprise.fulfillment_rate')}: {outlet.fulfillmentRate}%
</p>
</div>
</div>
);
})}
{inventoryData.filter(outlet => outlet.status !== 'normal').length === 0 && (
<div className="text-center py-8 text-[var(--color-success)]">
<CheckCircle2 className="w-8 h-8 mx-auto mb-2" />
<p>{t('enterprise.all_outlets_healthy')}</p>
</div>
)}
</div>
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
{t('enterprise.waiting_for_updates')}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
};
export default OutletFulfillmentTab;

View File

@@ -0,0 +1,410 @@
/*
* Production Tab Component for Enterprise Dashboard
* Shows network-wide production status and equipment monitoring
*/
import React, { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Button } from '../ui/Button';
import { Factory, AlertTriangle, CheckCircle2, Activity, Timer, Brain, Cog, Wrench, Bell, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ProductionStatusBlock } from './blocks/ProductionStatusBlock';
import StatusCard from '../ui/StatusCard/StatusCard';
import { useControlPanelData } from '../../api/hooks/useControlPanelData';
import { useSSEEvents } from '../../hooks/useSSE';
interface ProductionTabProps {
tenantId: string;
}
const ProductionTab: React.FC<ProductionTabProps> = ({ tenantId }) => {
const { t } = useTranslation('dashboard');
// Get control panel data for production information
const { data: controlPanelData, isLoading: isControlPanelLoading } = useControlPanelData(tenantId);
const isLoading = isControlPanelLoading;
// Real-time SSE events
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// State for equipment data with real-time updates
const [equipmentData, setEquipmentData] = useState([
{
id: 'oven-1',
name: 'Oven #1',
status: 'normal',
temperature: '180°C',
utilization: 85,
lastMaintenance: '2024-01-15',
nextMaintenance: '2024-02-15',
lastEvent: null
},
{
id: 'oven-2',
name: 'Oven #2',
status: 'warning',
temperature: '195°C',
utilization: 92,
lastMaintenance: '2024-01-10',
nextMaintenance: '2024-02-10',
lastEvent: null
},
{
id: 'mixer-1',
name: 'Industrial Mixer',
status: 'normal',
temperature: 'N/A',
utilization: 78,
lastMaintenance: '2024-01-20',
nextMaintenance: '2024-03-20',
lastEvent: null
},
{
id: 'proofer',
name: 'Proofing Chamber',
status: 'critical',
temperature: '32°C',
utilization: 65,
lastMaintenance: '2023-12-01',
nextMaintenance: '2024-01-31',
lastEvent: null
}
]);
// Process SSE events for equipment status updates
useEffect(() => {
if (sseEvents.length === 0) return;
// Filter equipment-related events
const equipmentEvents = sseEvents.filter(event =>
event.event_type.includes('equipment_') ||
event.event_type === 'equipment_maintenance' ||
event.entity_type === 'equipment'
);
if (equipmentEvents.length === 0) return;
// Update equipment status based on events
setEquipmentData(prevEquipment => {
return prevEquipment.map(equipment => {
// Find the latest event for this equipment
const equipmentEvent = equipmentEvents.find(event =>
event.entity_id === equipment.id ||
event.event_metadata?.equipment_id === equipment.id
);
if (equipmentEvent) {
// Update status based on event type
let newStatus = equipment.status;
let temperature = equipment.temperature;
let utilization = equipment.utilization;
switch (equipmentEvent.event_type) {
case 'equipment_maintenance_required':
case 'equipment_failure':
newStatus = 'critical';
break;
case 'equipment_warning':
case 'temperature_variance':
newStatus = 'warning';
break;
case 'equipment_normal':
case 'maintenance_completed':
newStatus = 'normal';
break;
}
// Update temperature if available in event metadata
if (equipmentEvent.event_metadata?.temperature) {
temperature = `${equipmentEvent.event_metadata.temperature}°C`;
}
// Update utilization if available in event metadata
if (equipmentEvent.event_metadata?.utilization) {
utilization = equipmentEvent.event_metadata.utilization;
}
return {
...equipment,
status: newStatus,
temperature: temperature,
utilization: utilization,
lastEvent: {
type: equipmentEvent.event_type,
timestamp: equipmentEvent.timestamp || new Date().toISOString(),
message: equipmentEvent.message
}
};
}
return equipment;
});
});
}, [sseEvents]);
return (
<div className="space-y-8">
{/* Production Status Block - Reusing existing component */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Factory className="w-6 h-6 text-[var(--color-primary)]" />
{t('production.title')}
</h2>
<ProductionStatusBlock
lateToStartBatches={controlPanelData?.lateToStartBatches || []}
runningBatches={controlPanelData?.runningBatches || []}
pendingBatches={controlPanelData?.pendingBatches || []}
alerts={controlPanelData?.alerts || []}
equipmentAlerts={controlPanelData?.equipmentAlerts || []}
productionAlerts={controlPanelData?.productionAlerts || []}
loading={isLoading}
/>
</div>
{/* Equipment Status Grid */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Cog className="w-6 h-6 text-[var(--color-secondary)]" />
{t('production.equipment_status')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{equipmentData.map((equipment) => {
// Determine status configuration
const getStatusConfig = () => {
switch (equipment.status) {
case 'critical':
return {
color: '#ef4444', // red-500
text: t('production.status_critical'),
icon: AlertTriangle,
isCritical: true
};
case 'warning':
return {
color: '#f59e0b', // amber-500
text: t('production.status_warning'),
icon: AlertTriangle,
isHighlight: true
};
default:
return {
color: '#10b981', // emerald-500
text: t('production.status_normal'),
icon: CheckCircle2
};
}
};
const statusConfig = getStatusConfig();
// Add real-time event indicator if there's a recent event
const eventMetadata = [];
if (equipment.lastEvent) {
const eventTime = new Date(equipment.lastEvent.timestamp);
eventMetadata.push(`🔔 ${equipment.lastEvent.type.replace(/_/g, ' ')} - ${eventTime.toLocaleTimeString()}`);
if (equipment.lastEvent.message) {
eventMetadata.push(`${t('production.event_message')}: ${equipment.lastEvent.message}`);
}
}
// Add SSE connection status to first card
const additionalMetadata = [];
if (equipment.id === 'oven-1') {
additionalMetadata.push(
sseConnected
? `🟢 ${t('enterprise.live_updates')}`
: `🟡 ${t('enterprise.offline')}`
);
}
return (
<StatusCard
key={equipment.id}
id={equipment.id}
statusIndicator={statusConfig}
title={equipment.name}
subtitle={equipment.temperature ? `${t('production.temperature')}: ${equipment.temperature}` : undefined}
primaryValue={`${equipment.utilization}%`}
primaryValueLabel={t('production.utilization')}
secondaryInfo={{
label: t('production.next_maintenance'),
value: new Date(equipment.nextMaintenance).toLocaleDateString()
}}
progress={{
label: t('production.utilization'),
percentage: equipment.utilization,
color: statusConfig.color
}}
metadata={[...eventMetadata, ...additionalMetadata,
`${t('production.last_maintenance')}: ${new Date(equipment.lastMaintenance).toLocaleDateString()}`
]}
actions={[
{
label: t('production.view_details'),
icon: Wrench,
variant: 'outline',
onClick: () => {
// In Phase 2, this will navigate to equipment detail page
console.log(`View details for ${equipment.name}`);
},
priority: 'primary'
}
]}
onClick={() => {
// In Phase 2, this will navigate to equipment detail page
console.log(`Clicked ${equipment.name}`);
}}
/>
);
})}
</div>
</div>
{/* Production Efficiency Metrics */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Brain className="w-6 h-6 text-[var(--color-info)]" />
{t('production.efficiency_metrics')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* On-time Batch Start Rate */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('production.on_time_start_rate')}
</CardTitle>
<Timer className="w-5 h-5 text-[var(--color-success)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{controlPanelData?.orchestrationSummary?.aiHandlingRate || 85}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('production.batches_started_on_time')}
</p>
</CardContent>
</Card>
{/* Production Efficiency */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('production.efficiency_rate')}
</CardTitle>
<Activity className="w-5 h-5 text-[var(--color-primary)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-primary)]">
{Math.round((controlPanelData?.issuesPreventedByAI || 0) /
Math.max(1, (controlPanelData?.issuesPreventedByAI || 0) + (controlPanelData?.issuesRequiringAction || 0)) * 100) || 92}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('production.overall_efficiency')}
</p>
</CardContent>
</Card>
{/* Active Production Alerts */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('production.active_alerts')}
</CardTitle>
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-warning)]">
{(controlPanelData?.productionAlerts?.length || 0) + (controlPanelData?.equipmentAlerts?.length || 0)}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('production.issues_require_attention')}
</p>
</CardContent>
</Card>
{/* AI Prevented Issues */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('production.ai_prevented')}
</CardTitle>
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{controlPanelData?.issuesPreventedByAI || 12}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('production.problems_prevented')}
</p>
</CardContent>
</Card>
</div>
</div>
{/* Quick Actions */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Timer className="w-6 h-6 text-[var(--color-info)]" />
{t('production.quick_actions')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Factory className="w-6 h-6 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('production.create_batch')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('production.create_batch_description')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/production/batches/create`}
className="w-full"
>
{t('production.create_batch')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Wrench className="w-6 h-6 text-[var(--color-success)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('production.maintenance')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('production.schedule_maintenance')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/equipment`}
variant="outline"
className="w-full"
>
{t('production.manage_equipment')}
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Cog className="w-6 h-6 text-[var(--color-info)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('production.quality_checks')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">{t('production.manage_quality')}</p>
<Button
onClick={() => window.location.href = `/app/tenants/${tenantId}/production/quality`}
variant="outline"
className="w-full"
>
{t('production.quality_management')}
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
export default ProductionTab;

View File

@@ -71,9 +71,37 @@
"items_needed": "items needed" "items_needed": "items needed"
}, },
"production": { "production": {
"title": "What needs to be produced today?", "title": "Production Status",
"empty": "No production scheduled for today", "empty": "No production scheduled for today",
"batches_pending": "batches pending" "batches_pending": "batches pending",
"equipment_status": "Equipment Status",
"temperature": "Temperature",
"utilization": "Utilization",
"next_maintenance": "Next Maintenance",
"last_maintenance": "Last Maintenance",
"view_details": "View Details",
"status_critical": "Critical",
"status_warning": "Warning",
"status_normal": "Normal",
"efficiency_metrics": "Production Efficiency Metrics",
"on_time_start_rate": "On-time Start Rate",
"batches_started_on_time": "Batches started on time",
"efficiency_rate": "Efficiency Rate",
"overall_efficiency": "Overall production efficiency",
"active_alerts": "Active Alerts",
"issues_require_attention": "Issues requiring attention",
"ai_prevented": "AI Prevented Issues",
"problems_prevented": "Problems prevented by AI",
"quick_actions": "Quick Actions",
"create_batch": "Create Production Batch",
"create_batch_description": "Create a new production batch for the network",
"maintenance": "Equipment Maintenance",
"schedule_maintenance": "Schedule maintenance for production equipment",
"manage_equipment": "Manage Equipment",
"quality_checks": "Quality Checks",
"manage_quality": "Manage quality control processes",
"quality_management": "Quality Management",
"event_message": "Message"
}, },
"po_approvals": { "po_approvals": {
"title": "What purchase orders need approval?", "title": "What purchase orders need approval?",
@@ -351,6 +379,7 @@
"network_sales": "Network Sales", "network_sales": "Network Sales",
"last_30_days": "last 30 days", "last_30_days": "last 30 days",
"production_volume": "Production Volume", "production_volume": "Production Volume",
"production": "Production",
"pending_orders": "Pending Orders", "pending_orders": "Pending Orders",
"internal_transfers": "internal transfers", "internal_transfers": "internal transfers",
"active_shipments": "Active Shipments", "active_shipments": "Active Shipments",
@@ -383,8 +412,61 @@
"metrics": { "metrics": {
"sales": "sales", "sales": "sales",
"inventory_value": "inventory value", "inventory_value": "inventory value",
"order_frequency": "order frequency" "order_frequency": "order frequency",
"on_time_delivery": "on-time delivery",
"inventory_turnover": "inventory turnover"
}, },
"network_performance": "Network Performance",
"performance_description": "Compare performance across all outlets in your network",
"network_summary": "Network Summary",
"performance_variance": "Performance Variance",
"top_to_bottom_spread": "Top to bottom performance spread",
"avg_sales": "Average Sales",
"avg_inventory": "Average Inventory",
"avg_orders": "Average Orders",
"per_outlet": "per outlet",
"total_outlets": "Total Outlets",
"locations_in_network": "locations in network",
"performance_insights": "Performance Insights",
"top_performer": "Top Performer",
"best_in_network": "Best performing outlet in network",
"needs_attention": "Needs Attention",
"improvement_opportunity": "Improvement opportunity identified",
"network_insight": "Network Insight",
"highly_balanced_network": "Highly balanced network",
"moderate_variation": "Moderate performance variation",
"high_variation": "High performance variation",
"balanced_network_description": "Your network shows excellent balance with minimal performance gaps",
"moderate_variation_description": "Some performance variation exists - consider knowledge sharing",
"high_variation_description": "Significant performance gaps detected - targeted improvement needed",
"analyze_performance": "Analyze Performance",
"outlet_comparison": "Outlet Comparison",
"chart_view": "Chart View",
"card_view": "Card View",
"improving": "Improving",
"declining": "Declining",
"stable": "Stable",
"no_data": "No Data",
"location": "Location",
"location_id": "Location ID",
"period": "Period",
"days": "days",
"of": "of",
"performance_index": "Performance Index",
"above_average": "Above Average",
"below_average": "Below Average",
"of_network_avg": "of network average",
"view_details": "View Details",
"performance_recommendations": "Performance Recommendations",
"best_practices": "Best Practices Sharing",
"learn_from_top_performer": "Learn from {name}'s best practices",
"targeted_improvement": "Targeted Improvement",
"focus_on_bottom_performer": "Focus improvement efforts on {name}",
"network_goal": "Network Performance Goal",
"reduce_variance_goal": "Reduce performance variance from {current}% to {target}%",
"set_network_targets": "Set Network Targets",
"schedule_knowledge_sharing": "Schedule Knowledge Sharing",
"create_improvement_plan": "Create Improvement Plan",
"route": "Route", "route": "Route",
"total_routes": "Total Routes", "total_routes": "Total Routes",
"total_distance": "Total Distance", "total_distance": "Total Distance",
@@ -407,7 +489,80 @@
"in_transit": "In Transit", "in_transit": "In Transit",
"delivered": "Delivered", "delivered": "Delivered",
"failed": "Failed", "failed": "Failed",
"distribution_routes": "Distribution Routes" "distribution_routes": "Distribution Routes",
"network_status": "Network Status",
"network_health": "Network Health Indicators",
"on_time_delivery": "On-time Delivery Rate",
"delivery_performance": "Delivery performance across network",
"issue_prevention": "Issue Prevention Rate",
"issues_prevented": "issues prevented by AI",
"active_issues": "Active Issues",
"action_required": "require immediate attention",
"network_efficiency": "Network Efficiency",
"operational_efficiency": "Operational efficiency score",
"add_outlet": "Add Outlet",
"add_outlet_description": "Add a new outlet to your bakery network",
"create_outlet": "Create Outlet",
"internal_transfers": "Internal Transfers",
"manage_transfers": "Manage transfers between central bakery and outlets",
"view_transfers": "View Transfers",
"view_alerts": "View Alerts",
"network_alerts_description": "View and manage network-wide alerts and issues",
"view_all_alerts": "View All Alerts",
"quick_actions": "Quick Actions",
"real_time_events": "Real-time Network Events",
"recent_activity": "Recent Activity",
"live_updates": "Live updates enabled",
"offline": "Offline mode",
"no_recent_activity": "No recent network activity",
"waiting_for_updates": "Waiting for real-time updates...",
"show_all_events": "Show all {count} events",
"show_less": "Show less",
"distribution_summary": "Distribution Summary",
"total_deliveries": "Total Deliveries",
"all_shipments": "All shipments today",
"on_time_deliveries": "On-time Deliveries",
"on_time_rate": "on-time rate",
"no_deliveries": "No deliveries yet",
"delayed_deliveries": "Delayed Deliveries",
"delay_rate": "delay rate",
"no_delays": "No delays",
"in_transit": "In Transit",
"currently_en_route": "Currently en route",
"route_optimization": "Route Optimization",
"distance_saved": "Distance Saved",
"total_distance_saved": "Total distance saved by optimization",
"time_saved": "Time Saved",
"total_time_saved": "Total time saved by optimization",
"fuel_saved": "Fuel Saved",
"estimated_fuel_savings": "Estimated fuel savings",
"co2_saved": "CO2 Saved",
"estimated_co2_reduction": "Estimated CO2 reduction",
"active_routes": "Active Routes",
"distance": "Distance",
"estimated_duration": "Estimated Duration",
"stops": "Stops",
"optimization": "Optimization",
"optimization_savings": "Optimization Savings",
"vehicles": "Vehicles",
"route_completed": "Route Completed",
"route_delayed": "Route Delayed",
"route_in_transit": "In Transit",
"route_pending": "Pending",
"track_route": "Track Route",
"real_time_delivery_events": "Real-time Delivery Events",
"recent_delivery_activity": "Recent Delivery Activity",
"no_recent_delivery_activity": "No recent delivery activity",
"route": "Route",
"optimize_routes": "Optimize Routes",
"optimize_routes_description": "Run route optimization for today's deliveries",
"run_optimization": "Run Optimization",
"manage_vehicles": "Manage Vehicles",
"manage_vehicle_fleet": "Manage vehicle fleet and assignments",
"view_vehicles": "View Vehicles",
"live_tracking": "Live GPS Tracking",
"real_time_gps_tracking": "Real-time GPS tracking of all vehicles",
"open_tracking_map": "Open Tracking Map"
}, },
"ai_insights": { "ai_insights": {
"title": "AI Insights", "title": "AI Insights",
@@ -465,7 +620,55 @@
"supplier_contract": "Contract with {supplier} for {products}", "supplier_contract": "Contract with {supplier} for {products}",
"seasonal_demand": "Seasonal increase of {increase}% in {products} for {season}", "seasonal_demand": "Seasonal increase of {increase}% in {products} for {season}",
"forecast_demand": "Forecasted demand for {product} with {confidence}% confidence for next {period, plural, one {# day} other {# days}}" "forecast_demand": "Forecasted demand for {product} with {confidence}% confidence for next {period, plural, one {# day} other {# days}}"
} },
"outlet_fulfillment": "Outlet Fulfillment",
"fulfillment_description": "Monitor inventory coverage, stockout risk, and fulfillment status across all outlets",
"fulfillment_summary": "Fulfillment Summary",
"network_health_score": "Network Health Score",
"overall_fulfillment_health": "Overall fulfillment health score",
"avg_inventory_coverage": "Avg Inventory Coverage",
"across_all_outlets": "across all outlets",
"fulfillment_rate": "Fulfillment Rate",
"order_fulfillment_rate": "Order fulfillment rate",
"critical_items": "Critical Items",
"items_at_risk": "items at risk of stockout",
"outlet_status_overview": "Outlet Status Overview",
"inventory_coverage": "Inventory Coverage",
"stockout_risk": "Stockout Risk",
"last_updated": "Last Updated",
"status_critical": "Critical Status",
"status_normal": "Normal Status",
"high_stockout_risk": "High Stockout Risk",
"medium_stockout_risk": "Medium Stockout Risk",
"risk_critical": "Critical",
"risk_high": "High",
"risk_medium": "Medium",
"risk_low": "Low",
"summary_view": "Summary View",
"detailed_view": "Detailed View",
"product_level_inventory": "Product Level Inventory",
"back_to_summary": "Back to Summary",
"current_stock": "Current Stock",
"safety_stock": "Safety Stock",
"coverage_of_safety": "Coverage of Safety Stock",
"stock_above_safety": "Stock Above Safety",
"yes": "Yes",
"no": "No",
"transfer_stock": "Transfer Stock",
"fulfillment_recommendations": "Fulfillment Recommendations",
"critical_outlets": "Critical Outlets",
"critical_outlets_description": "{count} outlets require immediate attention for inventory issues",
"inventory_optimization": "Inventory Optimization",
"low_coverage_recommendation": "Low inventory coverage detected - consider replenishment",
"good_coverage_recommendation": "Good inventory coverage - maintain current levels",
"fulfillment_excellence": "Fulfillment Excellence",
"high_fulfillment_congrats": "Excellent fulfillment rate of {rate}% - keep up the great work!",
"maintain_excellence": "Maintain Excellence",
"prioritize_transfers": "Prioritize Stock Transfers",
"run_optimization": "Run Optimization",
"real_time_inventory_alerts": "Real-time Inventory Alerts",
"recent_inventory_events": "Recent Inventory Events",
"all_outlets_healthy": "All outlets have healthy inventory levels"
}, },
"pending_deliveries": { "pending_deliveries": {
"title": "Pending Deliveries", "title": "Pending Deliveries",

View File

@@ -71,9 +71,37 @@
"items_needed": "artículos necesarios" "items_needed": "artículos necesarios"
}, },
"production": { "production": {
"title": "¿Qué necesito producir hoy?", "title": "Estado de Producción",
"empty": "Sin producción programada para hoy", "empty": "Sin producción programada para hoy",
"batches_pending": "lotes pendientes" "batches_pending": "lotes pendientes",
"equipment_status": "Estado del Equipo",
"temperature": "Temperatura",
"utilization": "Utilización",
"next_maintenance": "Próximo Mantenimiento",
"last_maintenance": "Último Mantenimiento",
"view_details": "Ver Detalles",
"status_critical": "Crítico",
"status_warning": "Advertencia",
"status_normal": "Normal",
"efficiency_metrics": "Métricas de Eficiencia de Producción",
"on_time_start_rate": "Tasa de Inicio a Tiempo",
"batches_started_on_time": "Lotes iniciados a tiempo",
"efficiency_rate": "Tasa de Eficiencia",
"overall_efficiency": "Eficiencia general de producción",
"active_alerts": "Alertas Activas",
"issues_require_attention": "Problemas que requieren atención",
"ai_prevented": "Problemas Prevenidos por IA",
"problems_prevented": "Problemas prevenidos por IA",
"quick_actions": "Acciones Rápidas",
"create_batch": "Crear Lote de Producción",
"create_batch_description": "Crear un nuevo lote de producción para la red",
"maintenance": "Mantenimiento de Equipos",
"schedule_maintenance": "Programar mantenimiento para equipos de producción",
"manage_equipment": "Gestionar Equipos",
"quality_checks": "Controles de Calidad",
"manage_quality": "Gestionar procesos de control de calidad",
"quality_management": "Gestión de Calidad",
"event_message": "Mensaje"
}, },
"po_approvals": { "po_approvals": {
"title": "¿Qué órdenes debo aprobar?", "title": "¿Qué órdenes debo aprobar?",
@@ -400,11 +428,36 @@
"network_sales": "Ventas de Red", "network_sales": "Ventas de Red",
"last_30_days": "últimos 30 días", "last_30_days": "últimos 30 días",
"production_volume": "Volumen de Producción", "production_volume": "Volumen de Producción",
"production": "Producción",
"pending_orders": "Órdenes Pendientes", "pending_orders": "Órdenes Pendientes",
"internal_transfers": "transferencias internas", "internal_transfers": "transferencias internas",
"active_shipments": "Envíos Activos", "active_shipments": "Envíos Activos",
"today": "hoy", "today": "hoy",
"distribution_map": "Rutas de Distribución", "distribution_map": "Rutas de Distribución",
"outlet_fulfillment": "Cumplimiento de Tiendas",
"fulfillment_description": "Monitorea la cobertura de inventario, el riesgo de ruptura de stock y el estado de cumplimiento en todas las tiendas",
"fulfillment_summary": "Resumen de Cumplimiento",
"network_health_score": "Puntuación de Salud de la Red",
"overall_fulfillment_health": "Puntuación general de salud de cumplimiento",
"avg_inventory_coverage": "Cobertura Promedio de Inventario",
"fulfillment_rate": "Tasa de Cumplimiento",
"order_fulfillment_rate": "Tasa de cumplimiento de pedidos",
"critical_items": "Artículos Críticos",
"items_at_risk": "artículos en riesgo de ruptura de stock",
"inventory_coverage": "Cobertura de Inventario",
"stockout_risk": "Riesgo de Ruptura de Stock",
"high_stockout_risk": "Alto Riesgo de Ruptura de Stock",
"medium_stockout_risk": "Riesgo Medio de Ruptura de Stock",
"fulfillment_recommendations": "Recomendaciones de Cumplimiento",
"inventory_optimization": "Optimización de Inventario",
"good_coverage_recommendation": "Buena cobertura de inventario - mantener niveles actuales",
"real_time_inventory_alerts": "Alertas de Inventario en Tiempo Real",
"recent_inventory_events": "Eventos Recientes de Inventario",
"across_all_outlets": "en todas las tiendas",
"outlet_status_overview": "Resumen del Estado de las Tiendas",
"status_normal": "Estado Normal",
"risk_low": "Bajo",
"risk_medium": "Medio",
"outlet_performance": "Rendimiento de Tiendas", "outlet_performance": "Rendimiento de Tiendas",
"sales": "Ventas", "sales": "Ventas",
"inventory_value": "Valor de Inventario", "inventory_value": "Valor de Inventario",
@@ -421,6 +474,57 @@
"no_performance_data": "No hay datos de rendimiento disponibles", "no_performance_data": "No hay datos de rendimiento disponibles",
"no_distribution_data": "No hay datos de distribución disponibles", "no_distribution_data": "No hay datos de distribución disponibles",
"performance_based_on": "Rendimiento basado en {{metric}} durante {{period}} días", "performance_based_on": "Rendimiento basado en {{metric}} durante {{period}} días",
"network_performance": "Rendimiento de Red",
"performance_description": "Comparar rendimiento en todas las tiendas de tu red",
"performance_variance": "Variación de Rendimiento",
"top_to_bottom_spread": "Diferencia entre mejor y peor rendimiento",
"avg_sales": "Ventas Promedio",
"avg_inventory": "Inventario Promedio",
"avg_orders": "Pedidos Promedio",
"per_outlet": "por tienda",
"total_outlets": "Total de Tiendas",
"locations_in_network": "ubicaciones en red",
"performance_insights": "Información de Rendimiento",
"top_performer": "Mejor Rendimiento",
"best_in_network": "Mejor tienda en la red",
"needs_attention": "Necesita Atención",
"improvement_opportunity": "Oportunidad de mejora identificada",
"network_insight": "Información de Red",
"high_variation": "Alta variación de rendimiento",
"high_variation_description": "Se detectaron brechas significativas de rendimiento - se necesita mejora dirigida",
"moderate_variation": "Variación moderada de rendimiento",
"moderate_variation_description": "Existe cierta variación de rendimiento - considera compartir conocimientos",
"highly_balanced_network": "Red altamente equilibrada",
"balanced_network_description": "Tu red muestra un excelente equilibrio con mínimas brechas de rendimiento",
"outlet_comparison": "Comparación de Tiendas",
"performance_index": "Índice de Rendimiento",
"above_average": "Por encima del promedio",
"below_average": "Por debajo del promedio",
"of_network_avg": "del promedio de la red",
"location": "Ubicación",
"location_id": "ID de Ubicación",
"period": "Período",
"days": "días",
"of": "de",
"no_data": "Sin Datos",
"stable": "Estable",
"improving": "Mejorando",
"declining": "Empeorando",
"view_details": "Ver Detalles",
"analyze_performance": "Analizar Rendimiento",
"card_view": "Vista de Tarjetas",
"chart_view": "Vista de Gráficos",
"performance_recommendations": "Recomendaciones de Rendimiento",
"best_practices": "Compartir Mejores Prácticas",
"learn_from_top_performer": "Aprender de las mejores prácticas de {name}",
"targeted_improvement": "Mejora Dirigida",
"focus_on_bottom_performer": "Enfocar esfuerzos de mejora en {name}",
"network_goal": "Objetivo de Rendimiento de Red",
"reduce_variance_goal": "Reducir variación de rendimiento de {current}% a {target}%",
"set_network_targets": "Establecer Objetivos de Red",
"schedule_knowledge_sharing": "Programar Compartición de Conocimientos",
"create_improvement_plan": "Crear Plan de Mejora",
"performance_based_on_period": "Rendimiento basado en {{metric}} durante {{period}} días",
"ranking": "Clasificación", "ranking": "Clasificación",
"rank": "Posición", "rank": "Posición",
"outlet": "Tienda", "outlet": "Tienda",
@@ -456,7 +560,80 @@
"in_transit": "En Tránsito", "in_transit": "En Tránsito",
"delivered": "Entregada", "delivered": "Entregada",
"failed": "Fallida", "failed": "Fallida",
"distribution_routes": "Rutas de Distribución" "distribution_routes": "Rutas de Distribución",
"network_status": "Estado de la Red",
"network_health": "Indicadores de Salud de la Red",
"on_time_delivery": "Tasa de Entrega a Tiempo",
"delivery_performance": "Rendimiento de entrega en toda la red",
"issue_prevention": "Tasa de Prevención de Problemas",
"issues_prevented": "problemas prevenidos por IA",
"active_issues": "Problemas Activos",
"action_required": "requieren atención inmediata",
"network_efficiency": "Eficiencia de la Red",
"operational_efficiency": "Puntuación de eficiencia operativa",
"add_outlet": "Agregar Punto de Venta",
"add_outlet_description": "Agregar un nuevo punto de venta a tu red de panaderías",
"create_outlet": "Crear Punto de Venta",
"internal_transfers": "Transferencias Internas",
"manage_transfers": "Gestionar transferencias entre obrador central y puntos de venta",
"view_transfers": "Ver Transferencias",
"view_alerts": "Ver Alertas",
"network_alerts_description": "Ver y gestionar alertas e incidencias en toda la red",
"view_all_alerts": "Ver Todas las Alertas",
"quick_actions": "Acciones Rápidas",
"real_time_events": "Eventos de Red en Tiempo Real",
"recent_activity": "Actividad Reciente",
"live_updates": "Actualizaciones en vivo activadas",
"offline": "Modo fuera de línea",
"no_recent_activity": "No hay actividad reciente en la red",
"waiting_for_updates": "Esperando actualizaciones en tiempo real...",
"show_all_events": "Mostrar todos los {count} eventos",
"show_less": "Mostrar menos",
"distribution_summary": "Resumen de Distribución",
"total_deliveries": "Entregas Totales",
"all_shipments": "Todos los envíos de hoy",
"on_time_deliveries": "Entregas a Tiempo",
"on_time_rate": "tasa de puntualidad",
"no_deliveries": "No hay entregas aún",
"delayed_deliveries": "Entregas Retrasadas",
"delay_rate": "tasa de retraso",
"no_delays": "No hay retrasos",
"in_transit": "En Tránsito",
"currently_en_route": "Actualmente en ruta",
"route_optimization": "Optimización de Rutas",
"distance_saved": "Distancia Ahorrada",
"total_distance_saved": "Distancia total ahorrada por optimización",
"time_saved": "Tiempo Ahorrado",
"total_time_saved": "Tiempo total ahorrado por optimización",
"fuel_saved": "Combustible Ahorrado",
"estimated_fuel_savings": "Ahorro estimado de combustible",
"co2_saved": "CO2 Ahorrado",
"estimated_co2_reduction": "Reducción estimada de CO2",
"active_routes": "Rutas Activas",
"distance": "Distancia",
"estimated_duration": "Duración Estimada",
"stops": "Paradas",
"optimization": "Optimización",
"optimization_savings": "Ahorro de Optimización",
"vehicles": "Vehículos",
"route_completed": "Ruta Completada",
"route_delayed": "Ruta Retrasada",
"route_in_transit": "En Tránsito",
"route_pending": "Pendiente",
"track_route": "Seguir Ruta",
"real_time_delivery_events": "Eventos de Entrega en Tiempo Real",
"recent_delivery_activity": "Actividad Reciente de Entrega",
"no_recent_delivery_activity": "No hay actividad reciente de entrega",
"route": "Ruta",
"optimize_routes": "Optimizar Rutas",
"optimize_routes_description": "Ejecutar optimización de rutas para las entregas de hoy",
"run_optimization": "Ejecutar Optimización",
"manage_vehicles": "Gestionar Vehículos",
"manage_vehicle_fleet": "Gestionar flota de vehículos y asignaciones",
"view_vehicles": "Ver Vehículos",
"live_tracking": "Seguimiento GPS en Vivo",
"real_time_gps_tracking": "Seguimiento GPS en tiempo real de todos los vehículos",
"open_tracking_map": "Abrir Mapa de Seguimiento"
}, },
"ai_insights": { "ai_insights": {
"title": "Insights de IA", "title": "Insights de IA",

View File

@@ -13,6 +13,7 @@ import {
} from '../../api/hooks/useEnterpriseDashboard'; } from '../../api/hooks/useEnterpriseDashboard';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card'; import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/Tabs';
import { import {
TrendingUp, TrendingUp,
MapPin, MapPin,
@@ -27,7 +28,11 @@ import {
PackageCheck, PackageCheck,
Building2, Building2,
ArrowLeft, ArrowLeft,
ChevronRight ChevronRight,
Target,
Warehouse,
ShoppingCart,
ShieldCheck
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner'; import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
@@ -35,11 +40,18 @@ import { ErrorBoundary } from 'react-error-boundary';
import { apiClient } from '../../api/client/apiClient'; import { apiClient } from '../../api/client/apiClient';
import { useEnterprise } from '../../contexts/EnterpriseContext'; import { useEnterprise } from '../../contexts/EnterpriseContext';
import { useTenant } from '../../stores/tenant.store'; import { useTenant } from '../../stores/tenant.store';
import { useSSEEvents } from '../../hooks/useSSE';
import { useQueryClient } from '@tanstack/react-query';
// Components for enterprise dashboard // Components for enterprise dashboard
const NetworkSummaryCards = React.lazy(() => import('../../components/dashboard/NetworkSummaryCards')); const NetworkSummaryCards = React.lazy(() => import('../../components/dashboard/NetworkSummaryCards'));
const DistributionMap = React.lazy(() => import('../../components/maps/DistributionMap')); const DistributionMap = React.lazy(() => import('../../components/maps/DistributionMap'));
const PerformanceChart = React.lazy(() => import('../../components/charts/PerformanceChart')); const PerformanceChart = React.lazy(() => import('../../components/charts/PerformanceChart'));
const NetworkOverviewTab = React.lazy(() => import('../../components/dashboard/NetworkOverviewTab'));
const NetworkPerformanceTab = React.lazy(() => import('../../components/dashboard/NetworkPerformanceTab'));
const OutletFulfillmentTab = React.lazy(() => import('../../components/dashboard/OutletFulfillmentTab'));
const ProductionTab = React.lazy(() => import('../../components/dashboard/ProductionTab'));
const DistributionTab = React.lazy(() => import('../../components/dashboard/DistributionTab'));
interface EnterpriseDashboardPageProps { interface EnterpriseDashboardPageProps {
tenantId?: string; tenantId?: string;
@@ -56,6 +68,51 @@ const EnterpriseDashboardPage: React.FC<EnterpriseDashboardPageProps> = ({ tenan
const [selectedMetric, setSelectedMetric] = useState('sales'); const [selectedMetric, setSelectedMetric] = useState('sales');
const [selectedPeriod, setSelectedPeriod] = useState(30); const [selectedPeriod, setSelectedPeriod] = useState(30);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [activeTab, setActiveTab] = useState('overview');
const queryClient = useQueryClient();
// SSE Integration for real-time updates
const { events: sseEvents } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// Invalidate enterprise data on relevant SSE events
useEffect(() => {
if (sseEvents.length === 0 || !tenantId) return;
const latest = sseEvents[0];
const relevantEventTypes = [
'batch_completed', 'batch_started', 'batch_state_changed',
'delivery_received', 'delivery_overdue', 'delivery_arriving_soon',
'stock_receipt_incomplete', 'orchestration_run_completed',
'production_delay', 'batch_start_delayed', 'equipment_maintenance',
'network_alert', 'outlet_performance_update', 'distribution_route_update'
];
if (relevantEventTypes.includes(latest.event_type)) {
// Invalidate all enterprise dashboard queries
queryClient.invalidateQueries({
queryKey: ['enterprise', 'network-summary', tenantId],
refetchType: 'active',
});
queryClient.invalidateQueries({
queryKey: ['enterprise', 'children-performance', tenantId],
refetchType: 'active',
});
queryClient.invalidateQueries({
queryKey: ['enterprise', 'distribution-overview', tenantId],
refetchType: 'active',
});
queryClient.invalidateQueries({
queryKey: ['enterprise', 'forecast-summary', tenantId],
refetchType: 'active',
});
queryClient.invalidateQueries({
queryKey: ['control-panel-data', tenantId],
refetchType: 'active',
});
}
}, [sseEvents, tenantId, queryClient]);
// Check if tenantId is available at the start // Check if tenantId is available at the start
useEffect(() => { useEffect(() => {
@@ -273,258 +330,187 @@ const EnterpriseDashboardPage: React.FC<EnterpriseDashboardPageProps> = ({ tenan
</div> </div>
</div> </div>
{/* Network Summary Cards */} {/* Main Tabs Structure */}
<div className="mb-8"> <Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<NetworkSummaryCards <TabsList className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-8 gap-2 mb-8">
data={networkSummary} <TabsTrigger value="overview">
isLoading={isNetworkSummaryLoading} <Network className="w-4 h-4 mr-2" />
/> {t('enterprise.network_status')}
</div> </TabsTrigger>
{/* Distribution Map and Performance Chart Row */} <TabsTrigger value="network-performance">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8"> <Target className="w-4 h-4 mr-2" />
{/* Distribution Map */} {t('enterprise.network_performance')}
<div> </TabsTrigger>
<Card className="h-full"> <TabsTrigger value="fulfillment">
<CardHeader className="flex flex-row items-center justify-between"> <Warehouse className="w-4 h-4 mr-2" />
<div className="flex items-center gap-2"> {t('enterprise.outlet_fulfillment')}
<Truck className="w-5 h-5 text-[var(--color-info)]" /> </TabsTrigger>
<CardTitle>{t('enterprise.distribution_map')}</CardTitle> <TabsTrigger value="distribution">
</div> <Truck className="w-4 h-4 mr-2" />
<div className="flex items-center gap-2"> {t('enterprise.distribution_map')}
<Calendar className="w-4 h-4 text-[var(--text-secondary)]" /> </TabsTrigger>
<input <TabsTrigger value="forecast">
type="date" <TrendingUp className="w-4 h-4 mr-2" />
value={selectedDate} {t('enterprise.network_forecast')}
onChange={(e) => setSelectedDate(e.target.value)} </TabsTrigger>
className="border border-[var(--border-primary)] rounded-md px-2 py-1 text-sm bg-[var(--input-bg)] text-[var(--text-primary)]" <TabsTrigger value="production">
/> <Package className="w-4 h-4 mr-2" />
</div> {t('enterprise.production')}
</CardHeader> </TabsTrigger>
<CardContent>
{distributionOverview ? (
<DistributionMap
routes={distributionOverview.route_sequences}
shipments={distributionOverview.status_counts}
/>
) : (
<div className="h-96 flex items-center justify-center text-[var(--text-secondary)]">
{t('enterprise.no_distribution_data')}
</div>
)}
</CardContent>
</Card>
</div>
{/* Performance Chart */} </TabsList>
<div>
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[var(--color-success)]" />
<CardTitle>{t('enterprise.outlet_performance')}</CardTitle>
</div>
<div className="flex gap-2">
<select
value={selectedMetric}
onChange={(e) => setSelectedMetric(e.target.value)}
className="border border-[var(--border-primary)] rounded-md px-2 py-1 text-sm bg-[var(--input-bg)] text-[var(--text-primary)]"
>
<option value="sales">{t('enterprise.metrics.sales')}</option>
<option value="inventory_value">{t('enterprise.metrics.inventory_value')}</option>
<option value="order_frequency">{t('enterprise.metrics.order_frequency')}</option>
</select>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(Number(e.target.value))}
className="border border-[var(--border-primary)] rounded-md px-2 py-1 text-sm bg-[var(--input-bg)] text-[var(--text-primary)]"
>
<option value={7}>{t('enterprise.last_7_days')}</option>
<option value={30}>{t('enterprise.last_30_days')}</option>
<option value={90}>{t('enterprise.last_90_days')}</option>
</select>
</div>
</CardHeader>
<CardContent>
{childrenPerformance ? (
<PerformanceChart
data={childrenPerformance.rankings}
metric={selectedMetric}
period={selectedPeriod}
onOutletClick={handleOutletClick}
/>
) : (
<div className="h-96 flex items-center justify-center text-[var(--text-secondary)]">
{t('enterprise.no_performance_data')}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Forecast Summary */} {/* Tab Content */}
<div className="mb-8"> <TabsContent value="overview">
<Card> <NetworkOverviewTab
<CardHeader className="flex flex-row items-center gap-2"> tenantId={tenantId!}
<TrendingUp className="w-5 h-5 text-[var(--color-primary)]" /> onOutletClick={handleOutletClick}
<CardTitle>{t('enterprise.network_forecast')}</CardTitle> />
</CardHeader> </TabsContent>
<CardContent>
{forecastSummary && forecastSummary.aggregated_forecasts ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Demand Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-info-100)' }}
>
<Package className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-info-800)' }}>
{t('enterprise.total_demand')}
</h3>
</div>
<p className="text-3xl font-bold" style={{ color: 'var(--color-info-900)' }}>
{Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0
).toLocaleString()}
</p>
</CardContent>
</Card>
{/* Days Forecast Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-success-100)' }}
>
<Calendar className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-success-800)' }}>
{t('enterprise.days_forecast')}
</h3>
</div>
<p className="text-3xl font-bold" style={{ color: 'var(--color-success-900)' }}>
{forecastSummary.days_forecast || 7}
</p>
</CardContent>
</Card>
{/* Average Daily Demand Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-secondary-100)' }}
>
<Activity className="w-5 h-5" style={{ color: 'var(--color-secondary-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-secondary-800)' }}>
{t('enterprise.avg_daily_demand')}
</h3>
</div>
<p className="text-3xl font-bold" style={{ color: 'var(--color-secondary-900)' }}>
{forecastSummary.aggregated_forecasts
? Math.round(Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0) /
Object.keys(forecastSummary.aggregated_forecasts).length
).toLocaleString()
: 0}
</p>
</CardContent>
</Card>
{/* Last Updated Card */} <TabsContent value="network-performance">
<Card className="hover:shadow-lg transition-shadow duration-300"> <NetworkPerformanceTab
<CardContent className="p-6"> tenantId={tenantId!}
<div className="flex items-center gap-3 mb-3"> onOutletClick={handleOutletClick}
<div />
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md" </TabsContent>
style={{ backgroundColor: 'var(--color-warning-100)' }}
>
<Clock className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-warning-800)' }}>
{t('enterprise.last_updated')}
</h3>
</div>
<p className="text-lg font-semibold" style={{ color: 'var(--color-warning-900)' }}>
{forecastSummary.last_updated ?
new Date(forecastSummary.last_updated).toLocaleTimeString() :
'N/A'}
</p>
</CardContent>
</Card>
</div>
) : (
<div className="flex items-center justify-center h-48 text-[var(--text-secondary)]">
{t('enterprise.no_forecast_data')}
</div>
)}
</CardContent>
</Card>
</div>
{/* Quick Actions */} <TabsContent value="distribution">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <DistributionTab
<Card> tenantId={tenantId!}
<CardContent className="p-6"> selectedDate={selectedDate}
<div className="flex items-center gap-3 mb-4"> onDateChange={setSelectedDate}
<Building2 className="w-6 h-6 text-[var(--color-primary)]" /> />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Agregar Punto de Venta</h3> </TabsContent>
</div>
<p className="text-[var(--text-secondary)] mb-4">Añadir un nuevo outlet a la red enterprise</p>
<Button
onClick={() => navigate(`/app/tenants/${tenantId}/settings/organization`)}
className="w-full"
>
Crear Outlet
</Button>
</CardContent>
</Card>
<Card> <TabsContent value="forecast">
<CardContent className="p-6"> {/* Forecast Summary */}
<div className="flex items-center gap-3 mb-4"> <div className="mb-8">
<PackageCheck className="w-6 h-6 text-[var(--color-success)]" /> <Card>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Transferencias Internas</h3> <CardHeader className="flex flex-row items-center gap-2">
</div> <TrendingUp className="w-5 h-5 text-[var(--color-primary)]" />
<p className="text-[var(--text-secondary)] mb-4">Gestionar pedidos entre obrador central y outlets</p> <CardTitle>{t('enterprise.network_forecast')}</CardTitle>
<Button </CardHeader>
onClick={() => navigate(`/app/tenants/${tenantId}/procurement/internal-transfers`)} <CardContent>
variant="outline" {forecastSummary && forecastSummary.aggregated_forecasts ? (
className="w-full" <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
> {/* Total Demand Card */}
Ver Transferencias <Card className="hover:shadow-lg transition-shadow duration-300">
</Button> <CardContent className="p-6">
</CardContent> <div className="flex items-center gap-3 mb-3">
</Card> <div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-info-100)' }}
>
<Package className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-info-800)' }}>
{t('enterprise.total_demand')}
</h3>
</div>
<p className="text-3xl font-bold" style={{ color: 'var(--color-info-900)' }}>
{Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0
).toLocaleString()}
</p>
</CardContent>
</Card>
<Card> {/* Days Forecast Card */}
<CardContent className="p-6"> <Card className="hover:shadow-lg transition-shadow duration-300">
<div className="flex items-center gap-3 mb-4"> <CardContent className="p-6">
<MapPin className="w-6 h-6 text-[var(--color-info)]" /> <div className="flex items-center gap-3 mb-3">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Rutas de Distribución</h3> <div
</div> className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
<p className="text-[var(--text-secondary)] mb-4">Optimizar rutas de entrega entre ubicaciones</p> style={{ backgroundColor: 'var(--color-success-100)' }}
<Button >
onClick={() => navigate(`/app/tenants/${tenantId}/distribution/routes`)} <Calendar className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
variant="outline" </div>
className="w-full" <h3 className="font-semibold text-sm" style={{ color: 'var(--color-success-800)' }}>
> {t('enterprise.days_forecast')}
Ver Rutas </h3>
</Button> </div>
</CardContent> <p className="text-3xl font-bold" style={{ color: 'var(--color-success-900)' }}>
</Card> {forecastSummary.days_forecast || 7}
</div> </p>
</CardContent>
</Card>
{/* Average Daily Demand Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-secondary-100)' }}
>
<Activity className="w-5 h-5" style={{ color: 'var(--color-secondary-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-secondary-800)' }}>
{t('enterprise.avg_daily_demand')}
</h3>
</div>
<p className="text-3xl font-bold" style={{ color: 'var(--color-secondary-900)' }}>
{forecastSummary.aggregated_forecasts
? Math.round(Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0) /
Object.keys(forecastSummary.aggregated_forecasts).length
).toLocaleString()
: 0}
</p>
</CardContent>
</Card>
{/* Last Updated Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-warning-100)' }}
>
<Clock className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-warning-800)' }}>
{t('enterprise.last_updated')}
</h3>
</div>
<p className="text-lg font-semibold" style={{ color: 'var(--color-warning-900)' }}>
{forecastSummary.last_updated ?
new Date(forecastSummary.last_updated).toLocaleTimeString() :
'N/A'}
</p>
</CardContent>
</Card>
</div>
) : (
<div className="flex items-center justify-center h-48 text-[var(--text-secondary)]">
{t('enterprise.no_forecast_data')}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="fulfillment">
<OutletFulfillmentTab
tenantId={tenantId!}
onOutletClick={handleOutletClick}
/>
</TabsContent>
<TabsContent value="production">
<ProductionTab tenantId={tenantId!} />
</TabsContent>
</Tabs>
</div> </div>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@@ -125,6 +125,11 @@ spec:
limits: limits:
memory: "512Mi" memory: "512Mi"
cpu: "500m" cpu: "500m"
securityContext:
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
volumeMounts: volumeMounts:
- name: redis-tls - name: redis-tls
mountPath: /tls mountPath: /tls

48
scripts/fix_inotify_limits.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Script to fix "too many open files" error in Kubernetes
# This error occurs when the system hits inotify limits
echo "Fixing inotify limits for Kubernetes..."
# Check current inotify limits
echo "Current inotify limits:"
sysctl fs.inotify.max_user_watches
sysctl fs.inotify.max_user_instances
sysctl fs.inotify.max_queued_events
echo ""
echo "Increasing inotify limits..."
# Increase inotify limits (temporary - lasts until reboot)
sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=1024
sudo sysctl -w fs.inotify.max_queued_events=16384
# Verify the changes
echo ""
echo "New inotify limits:"
sysctl fs.inotify.max_user_watches
sysctl fs.inotify.max_user_instances
sysctl fs.inotify.max_queued_events
echo ""
echo "For permanent fix, add these lines to /etc/sysctl.conf:"
echo "fs.inotify.max_user_watches=524288"
echo "fs.inotify.max_user_instances=1024"
echo "fs.inotify.max_queued_events=16384"
echo ""
echo "Then run: sudo sysctl -p"
echo ""
echo "If you're using Docker Desktop or Kind, you may need to:"
echo "1. Restart Docker Desktop"
echo "2. Or for Kind: kind delete cluster && kind create cluster"
echo "3. Or adjust the node's system limits directly"
echo ""
echo "For production environments, consider adding these limits to your deployment:"
echo "securityContext:"
echo " runAsUser: 1000"
echo " runAsGroup: 1000"
echo " fsGroup: 1000"

100
scripts/fix_kubernetes_inotify.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/bin/bash
# Script to fix "too many open files" error in Kubernetes
# This error occurs when the system hits inotify limits
echo "🔧 Fixing Kubernetes inotify limits..."
# Check if we're running on macOS (Docker Desktop) or Linux
if [[ "$(uname)" == "Darwin" ]]; then
echo "🍎 Detected macOS - Docker Desktop environment"
echo ""
echo "For Docker Desktop on macOS, you need to:"
echo "1. Open Docker Desktop settings"
echo "2. Go to 'Resources' -> 'Advanced'"
echo "3. Increase the memory allocation (recommended: 8GB+)"
echo "4. Restart Docker Desktop"
echo ""
echo "Alternatively, you can run:"
echo "docker system prune -a --volumes"
echo "Then restart Docker Desktop"
# Also check if we can adjust macOS system limits
echo ""
echo "Checking current macOS inotify limits..."
sysctl kern.maxfilesperproc
sysctl kern.maxfiles
echo ""
echo "To increase macOS limits permanently, add to /etc/sysctl.conf:"
echo "kern.maxfiles=1048576"
echo "kern.maxfilesperproc=65536"
echo "Then run: sudo sysctl -w kern.maxfiles=1048576"
echo "And: sudo sysctl -w kern.maxfilesperproc=65536"
elif [[ "$(uname)" == "Linux" ]]; then
echo "🐧 Detected Linux environment"
# Check if we're in a Kubernetes cluster
if kubectl cluster-info >/dev/null 2>&1; then
echo "🎯 Detected Kubernetes cluster"
# Check current inotify limits
echo ""
echo "Current inotify limits:"
sysctl fs.inotify.max_user_watches
sysctl fs.inotify.max_user_instances
sysctl fs.inotify.max_queued_events
# Increase limits temporarily
echo ""
echo "Increasing inotify limits temporarily..."
sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=1024
sudo sysctl -w fs.inotify.max_queued_events=16384
# Verify changes
echo ""
echo "New inotify limits:"
sysctl fs.inotify.max_user_watches
sysctl fs.inotify.max_user_instances
sysctl fs.inotify.max_queued_events
# Check if we can make permanent changes
if [[ -f /etc/sysctl.conf ]]; then
echo ""
echo "Making inotify limits permanent..."
sudo bash -c 'cat >> /etc/sysctl.conf << EOF
# Increased inotify limits for Kubernetes
fs.inotify.max_user_watches=524288
fs.inotify.max_user_instances=1024
fs.inotify.max_queued_events=16384
EOF'
sudo sysctl -p
fi
# Check for Docker containers that might need restarting
echo ""
echo "Checking for running containers that might need restarting..."
docker ps --format "{{.Names}}" | while read container; do
echo "Restarting container: $container"
docker restart "$container" >/dev/null 2>&1 || echo "Failed to restart $container"
done
else
echo "⚠️ Kubernetes cluster not detected"
echo "This script should be run on a Kubernetes node or with kubectl access"
fi
else
echo "❓ Unsupported operating system: $(uname)"
fi
echo ""
echo "📋 Additional recommendations:"
echo "1. For Kind clusters: kind delete cluster && kind create cluster"
echo "2. For Minikube: minikube stop && minikube start"
echo "3. For production: Adjust node system limits and restart kubelet"
echo "4. Consider adding resource limits to your deployments"
echo ""
echo "✅ Inotify fix script completed!"

View File

@@ -0,0 +1,341 @@
"""
VRP Optimization API Endpoints
Endpoints for VRP optimization and metrics retrieval
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
import structlog
from app.services.vrp_optimization_service import VRPOptimizationService
from app.services.distribution_service import DistributionService
from shared.auth.tenant_access import verify_tenant_permission_dep
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter()
# Pydantic models for request/response
class VRPOptimizationRequest(BaseModel):
algorithm_version: str = Field(default="v2.1", description="VRP algorithm version to use")
constraints: Optional[Dict[str, Any]] = Field(
None,
description="Optimization constraints: max_route_duration, max_route_distance, etc."
)
class VRPOptimizationResponse(BaseModel):
success: bool
route_id: str
optimization_savings: Dict[str, Any]
vrp_algorithm_version: str
vrp_optimization_timestamp: str
vrp_constraints_satisfied: bool
vrp_objective_value: float
class RouteOptimizationMetrics(BaseModel):
route_id: str
route_number: str
route_date: str
vrp_optimization_savings: Optional[Dict[str, Any]]
vrp_algorithm_version: Optional[str]
vrp_optimization_timestamp: Optional[str]
vrp_constraints_satisfied: Optional[bool]
vrp_objective_value: Optional[float]
total_distance_km: Optional[float]
estimated_duration_minutes: Optional[int]
class NetworkOptimizationSummary(BaseModel):
total_routes: int
optimized_routes: int
total_distance_saved_km: float
total_time_saved_minutes: float
total_fuel_saved_liters: float
total_co2_saved_kg: float
total_cost_saved_eur: float
optimization_rate: float
average_savings_per_route: Optional[Dict[str, Any]]
class OptimizationHistoryItem(BaseModel):
optimization_id: str
route_id: str
timestamp: str
algorithm_version: str
distance_saved_km: float
time_saved_minutes: float
fuel_saved_liters: float
co2_saved_kg: float
cost_saved_eur: float
constraints_satisfied: bool
async def get_vrp_optimization_service() -> VRPOptimizationService:
"""Dependency injection for VRPOptimizationService"""
from app.core.database import database_manager
from app.services.distribution_service import DistributionService as BusinessDistributionService
from app.repositories.delivery_route_repository import DeliveryRouteRepository
from app.repositories.shipment_repository import ShipmentRepository
from app.repositories.delivery_schedule_repository import DeliveryScheduleRepository
from shared.clients.tenant_client import TenantServiceClient
from shared.clients.inventory_client import InventoryServiceClient
from shared.clients.procurement_client import ProcurementServiceClient
from app.services.routing_optimizer import RoutingOptimizer
# Create the business distribution service with proper dependencies
route_repository = DeliveryRouteRepository(database_manager.get_session())
shipment_repository = ShipmentRepository(database_manager.get_session())
schedule_repository = DeliveryScheduleRepository(database_manager.get_session())
# Create client instances (these will be initialized with proper config)
tenant_client = TenantServiceClient()
inventory_client = InventoryServiceClient()
procurement_client = ProcurementServiceClient()
routing_optimizer = RoutingOptimizer()
distribution_service = BusinessDistributionService(
route_repository=route_repository,
shipment_repository=shipment_repository,
schedule_repository=schedule_repository,
procurement_client=procurement_client,
tenant_client=tenant_client,
inventory_client=inventory_client,
routing_optimizer=routing_optimizer
)
return VRPOptimizationService(distribution_service, database_manager)
@router.post("/tenants/{tenant_id}/routes/{route_id}/optimize",
response_model=VRPOptimizationResponse,
summary="Optimize delivery route with VRP")
async def optimize_route_with_vrp(
tenant_id: str,
route_id: str,
optimization_request: VRPOptimizationRequest,
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Optimize a delivery route using VRP algorithm
This endpoint applies VRP optimization to a specific delivery route and stores
the optimization metrics for analysis and reporting.
"""
try:
result = await vrp_service.optimize_route_with_vrp(
route_id=route_id,
algorithm_version=optimization_request.algorithm_version,
constraints=optimization_request.constraints
)
if not result.get('success'):
raise HTTPException(status_code=500, detail="Optimization failed")
return VRPOptimizationResponse(
success=True,
route_id=result['route_id'],
optimization_savings=result['optimization_savings'],
vrp_algorithm_version=result['optimization_savings'].get('algorithm_version', optimization_request.algorithm_version),
vrp_optimization_timestamp=result['optimization_savings'].get('timestamp', datetime.now().isoformat()),
vrp_constraints_satisfied=result['optimization_savings'].get('constraints_satisfied', True),
vrp_objective_value=result['optimization_savings'].get('objective_value', 0.0)
)
except Exception as e:
logger.error("VRP optimization failed", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"VRP optimization failed: {str(e)}")
@router.get("/tenants/{tenant_id}/routes/{route_id}/optimization-metrics",
response_model=RouteOptimizationMetrics,
summary="Get VRP optimization metrics for route")
async def get_route_optimization_metrics(
tenant_id: str,
route_id: str,
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get VRP optimization metrics for a specific route
Retrieves stored optimization metrics including savings, algorithm version,
and constraint satisfaction status.
"""
try:
metrics = await vrp_service.get_route_optimization_metrics(route_id)
return RouteOptimizationMetrics(**metrics)
except Exception as e:
logger.error("Failed to get route optimization metrics", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get optimization metrics: {str(e)}")
@router.get("/tenants/{tenant_id}/vrp/optimization-summary",
response_model=NetworkOptimizationSummary,
summary="Get network-wide VRP optimization summary")
async def get_network_optimization_summary(
tenant_id: str,
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get aggregated VRP optimization metrics across all routes
Provides network-wide summary of optimization benefits including
total savings, optimization rate, and average improvements.
"""
try:
summary = await vrp_service.get_network_optimization_summary(tenant_id)
return NetworkOptimizationSummary(**summary)
except Exception as e:
logger.error("Failed to get network optimization summary", tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get optimization summary: {str(e)}")
@router.post("/tenants/{tenant_id}/vrp/batch-optimize",
summary="Batch optimize multiple routes")
async def batch_optimize_routes(
tenant_id: str,
route_ids: List[str] = Query(..., description="List of route IDs to optimize"),
algorithm_version: str = Query("v2.1", description="VRP algorithm version"),
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Batch optimize multiple delivery routes with VRP
Applies VRP optimization to multiple routes in a single request.
"""
try:
result = await vrp_service.batch_optimize_routes(tenant_id, route_ids)
return {
'success': True,
'total_routes_processed': result['total_routes_processed'],
'successful_optimizations': result['successful_optimizations'],
'failed_optimizations': result['failed_optimizations'],
'results': result['results']
}
except Exception as e:
logger.error("Batch optimization failed", tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Batch optimization failed: {str(e)}")
@router.get("/tenants/{tenant_id}/routes/{route_id}/optimization-history",
response_model=List[OptimizationHistoryItem],
summary="Get optimization history for route")
async def get_optimization_history(
tenant_id: str,
route_id: str,
limit: int = Query(10, description="Maximum number of historical records to return"),
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get historical optimization records for a route
Retrieves past optimization runs and their results for analysis.
"""
try:
history = await vrp_service.get_optimization_history(route_id, limit)
return [OptimizationHistoryItem(**item) for item in history]
except Exception as e:
logger.error("Failed to get optimization history", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get optimization history: {str(e)}")
@router.get("/tenants/{tenant_id}/vrp/constraints/validate",
summary="Validate VRP constraints")
async def validate_vrp_constraints(
tenant_id: str,
route_id: str,
max_route_duration: Optional[int] = Query(None, description="Maximum route duration in minutes"),
max_route_distance: Optional[float] = Query(None, description="Maximum route distance in km"),
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Validate VRP constraints against a route
Checks if a route satisfies specified VRP constraints.
"""
try:
from app.services.vrp_optimization_service import VRPConstraintValidator
# Get route data
route = await vrp_service.distribution_service.get_delivery_route(route_id)
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Build constraints dict
constraints = {}
if max_route_duration is not None:
constraints['max_route_duration'] = max_route_duration
if max_route_distance is not None:
constraints['max_route_distance'] = max_route_distance
# Validate constraints
validation_result = VRPConstraintValidator.validate_constraints(route, constraints)
return {
'success': True,
'all_constraints_satisfied': validation_result['all_satisfied'],
'constraint_violations': validation_result['constraint_violations']
}
except Exception as e:
logger.error("Failed to validate VRP constraints", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to validate constraints: {str(e)}")
@router.post("/tenants/{tenant_id}/vrp/simulate",
summary="Simulate VRP optimization")
async def simulate_vrp_optimization(
tenant_id: str,
route_id: str,
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Simulate VRP optimization without saving results
Useful for testing and previewing optimization results.
"""
try:
from app.services.vrp_optimization_service import VRPOptimizationSimulator
# Get route data
route = await vrp_service.distribution_service.get_delivery_route(route_id)
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Simulate optimization
simulation_result = VRPOptimizationSimulator.simulate_optimization(route)
return {
'success': True,
'original_route': simulation_result['original_route'],
'optimized_route': simulation_result['optimized_route'],
'optimization_savings': simulation_result['optimization_savings'],
'algorithm_version': simulation_result['algorithm_version'],
'constraints_satisfied': simulation_result['constraints_satisfied'],
'objective_value': simulation_result['objective_value']
}
except Exception as e:
logger.error("VRP simulation failed", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"VRP simulation failed: {str(e)}")
# Import datetime at runtime to avoid circular imports
from datetime import datetime

View File

@@ -9,6 +9,7 @@ from app.core.database import database_manager
from app.api.routes import router as distribution_router from app.api.routes import router as distribution_router
from app.api.shipments import router as shipments_router from app.api.shipments import router as shipments_router
from app.api.internal_demo import router as internal_demo_router from app.api.internal_demo import router as internal_demo_router
from app.api.vrp_optimization import router as vrp_optimization_router
from shared.service_base import StandardFastAPIService from shared.service_base import StandardFastAPIService
@@ -122,4 +123,5 @@ service.setup_standard_endpoints()
# Note: Routes now use RouteBuilder which includes full paths, so no prefix needed # Note: Routes now use RouteBuilder which includes full paths, so no prefix needed
service.add_router(distribution_router, tags=["distribution"]) service.add_router(distribution_router, tags=["distribution"])
service.add_router(shipments_router, tags=["shipments"]) service.add_router(shipments_router, tags=["shipments"])
service.add_router(internal_demo_router, tags=["internal-demo"]) service.add_router(internal_demo_router, tags=["internal-demo"])
service.add_router(vrp_optimization_router, tags=["vrp-optimization"])

View File

@@ -58,6 +58,13 @@ class DeliveryRoute(Base):
total_distance_km = Column(Float, nullable=True) total_distance_km = Column(Float, nullable=True)
estimated_duration_minutes = Column(Integer, nullable=True) estimated_duration_minutes = Column(Integer, nullable=True)
# VRP Optimization metrics (Phase 2 enhancement)
vrp_optimization_savings = Column(JSONB, nullable=True) # {"distance_saved_km": 12.5, "time_saved_minutes": 25, "fuel_saved_liters": 8.2, "co2_saved_kg": 15.4, "cost_saved_eur": 12.50}
vrp_algorithm_version = Column(String(50), nullable=True) # Version of VRP algorithm used
vrp_optimization_timestamp = Column(DateTime(timezone=True), nullable=True) # When optimization was performed
vrp_constraints_satisfied = Column(Boolean, nullable=True) # Whether all constraints were satisfied
vrp_objective_value = Column(Float, nullable=True) # Objective function value from optimization
# Route details # Route details
route_sequence = Column(JSONB, nullable=True) # Ordered array of stops with timing: [{"stop_number": 1, "location_id": "...", "estimated_arrival": "...", "actual_arrival": "..."}] route_sequence = Column(JSONB, nullable=True) # Ordered array of stops with timing: [{"stop_number": 1, "location_id": "...", "estimated_arrival": "...", "actual_arrival": "..."}]
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)

View File

@@ -231,4 +231,82 @@ class DeliveryRouteRepository:
await self.db_session.commit() await self.db_session.commit()
deleted_count = result.rowcount deleted_count = result.rowcount
return deleted_count return deleted_count
async def update_route_vrp_metrics(self, route_id: str, vrp_metrics: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Update VRP optimization metrics for a route
"""
stmt = select(DeliveryRoute).where(DeliveryRoute.id == route_id)
result = await self.db_session.execute(stmt)
route = result.scalar_one_or_none()
if not route:
return None
# Update VRP metrics fields
route.vrp_optimization_savings = vrp_metrics.get('vrp_optimization_savings')
route.vrp_algorithm_version = vrp_metrics.get('vrp_algorithm_version')
route.vrp_optimization_timestamp = vrp_metrics.get('vrp_optimization_timestamp')
route.vrp_constraints_satisfied = vrp_metrics.get('vrp_constraints_satisfied')
route.vrp_objective_value = vrp_metrics.get('vrp_objective_value')
await self.db_session.commit()
await self.db_session.refresh(route)
return {
'id': str(route.id),
'vrp_optimization_savings': route.vrp_optimization_savings,
'vrp_algorithm_version': route.vrp_algorithm_version,
'vrp_optimization_timestamp': route.vrp_optimization_timestamp,
'vrp_constraints_satisfied': route.vrp_constraints_satisfied,
'vrp_objective_value': route.vrp_objective_value
}
async def get_routes_by_tenant(self, tenant_id: str, limit: int = None, offset: int = None, order_by: str = None) -> List[Dict[str, Any]]:
"""
Get all routes for a specific tenant with pagination and ordering
"""
stmt = select(DeliveryRoute).where(DeliveryRoute.tenant_id == tenant_id)
# Apply ordering if specified
if order_by:
if 'vrp_optimization_timestamp' in order_by:
if 'DESC' in order_by:
stmt = stmt.order_by(DeliveryRoute.vrp_optimization_timestamp.desc())
else:
stmt = stmt.order_by(DeliveryRoute.vrp_optimization_timestamp.asc())
elif 'route_date' in order_by:
if 'DESC' in order_by:
stmt = stmt.order_by(DeliveryRoute.route_date.desc())
else:
stmt = stmt.order_by(DeliveryRoute.route_date.asc())
# Apply pagination if specified
if limit is not None:
stmt = stmt.limit(limit)
if offset is not None:
stmt = stmt.offset(offset)
result = await self.db_session.execute(stmt)
routes = result.scalars().all()
return [{
'id': str(route.id),
'tenant_id': str(route.tenant_id),
'route_number': route.route_number,
'route_date': route.route_date,
'vehicle_id': route.vehicle_id,
'driver_id': route.driver_id,
'total_distance_km': route.total_distance_km,
'estimated_duration_minutes': route.estimated_duration_minutes,
'route_sequence': route.route_sequence,
'status': route.status.value if hasattr(route.status, 'value') else route.status,
'created_at': route.created_at,
'updated_at': route.updated_at,
'vrp_optimization_savings': route.vrp_optimization_savings,
'vrp_algorithm_version': route.vrp_algorithm_version,
'vrp_optimization_timestamp': route.vrp_optimization_timestamp,
'vrp_constraints_satisfied': route.vrp_constraints_satisfied,
'vrp_objective_value': route.vrp_objective_value
} for route in routes]

View File

@@ -302,4 +302,23 @@ class DistributionService:
except Exception as e: except Exception as e:
logger.error(f"Error creating delivery schedule: {e}") logger.error(f"Error creating delivery schedule: {e}")
raise raise
# VRP Optimization Service Methods
async def get_route_by_id(self, route_id: str) -> Optional[Dict[str, Any]]:
"""
Get a specific delivery route by ID
"""
return await self.route_repository.get_route_by_id(route_id)
async def update_route_vrp_metrics(self, route_id: str, vrp_metrics: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Update VRP optimization metrics for a route
"""
return await self.route_repository.update_route_vrp_metrics(route_id, vrp_metrics)
async def get_routes_by_tenant(self, tenant_id: str, limit: int = None, offset: int = None, order_by: str = None) -> List[Dict[str, Any]]:
"""
Get all routes for a specific tenant with pagination and ordering
"""
return await self.route_repository.get_routes_by_tenant(tenant_id, limit, offset, order_by)

View File

@@ -0,0 +1,357 @@
"""
VRP Optimization Service
Business logic for VRP optimization and metrics management
"""
from typing import List, Dict, Any, Optional
from datetime import datetime
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.delivery_route_repository import DeliveryRouteRepository
from app.services.routing_optimizer import RoutingOptimizer
from app.core.database import get_db
logger = structlog.get_logger()
class VRPOptimizationService:
"""
Service for VRP optimization operations
"""
def __init__(self, distribution_service: "DistributionService", database_manager: Any):
"""
Initialize VRP optimization service
Args:
distribution_service: Distribution service instance
database_manager: Database manager for session management
"""
self.distribution_service = distribution_service
self.database_manager = database_manager
self.routing_optimizer = RoutingOptimizer()
async def optimize_route(
self,
tenant_id: str,
route_id: str,
optimization_params: Dict[str, Any]
) -> Dict[str, Any]:
"""
Optimize a specific delivery route using VRP
Args:
tenant_id: Tenant ID
route_id: Route ID to optimize
optimization_params: Optimization parameters
Returns:
Optimization result with metrics
"""
try:
# Get the current route using distribution service
route = await self.distribution_service.get_route_by_id(route_id)
if not route:
raise ValueError(f"Route {route_id} not found")
# Extract deliveries from route sequence
deliveries = self._extract_deliveries_from_route(route)
# Perform VRP optimization
depot_location = optimization_params.get('depot_location', (0.0, 0.0))
vehicle_capacity = optimization_params.get('vehicle_capacity_kg', 1000.0)
time_limit = optimization_params.get('time_limit_seconds', 30.0)
optimization_result = await self.routing_optimizer.optimize_daily_routes(
deliveries=deliveries,
depot_location=depot_location,
vehicle_capacity_kg=vehicle_capacity,
time_limit_seconds=time_limit
)
# Update route with optimization metrics
vrp_metrics = {
'vrp_optimization_savings': {
'distance_saved_km': optimization_result.get('distance_savings_km', 0.0),
'time_saved_minutes': optimization_result.get('time_savings_minutes', 0.0),
'cost_saved': optimization_result.get('cost_savings', 0.0)
},
'vrp_algorithm_version': 'or-tools-v1.0',
'vrp_optimization_timestamp': datetime.utcnow(),
'vrp_constraints_satisfied': optimization_result.get('constraints_satisfied', True),
'vrp_objective_value': optimization_result.get('objective_value', 0.0)
}
# Update the route with VRP metrics using distribution service
await self.distribution_service.update_route_vrp_metrics(route_id, vrp_metrics)
return {
'success': True,
'route_id': route_id,
'optimization_metrics': vrp_metrics,
'optimized_route': optimization_result.get('optimized_route', [])
}
except Exception as e:
logger.error("vrp_optimization_failed", error=str(e), route_id=route_id)
raise
def _extract_deliveries_from_route(self, route: Any) -> List[Dict[str, Any]]:
"""
Extract deliveries from route sequence
Args:
route: Delivery route object
Returns:
List of delivery dictionaries
"""
deliveries = []
route_sequence = route.route_sequence or []
for stop in route_sequence:
deliveries.append({
'id': stop.get('id', ''),
'location': (stop.get('lat', 0.0), stop.get('lng', 0.0)),
'weight_kg': stop.get('weight_kg', 0.0),
'time_window': stop.get('time_window')
})
return deliveries
async def get_route_optimization_metrics(
self,
tenant_id: str,
route_id: str
) -> Dict[str, Any]:
"""
Get VRP optimization metrics for a specific route
Args:
tenant_id: Tenant ID
route_id: Route ID
Returns:
VRP optimization metrics
"""
route = await self.route_repository.get_route_by_id(route_id)
if not route:
raise ValueError(f"Route {route_id} not found")
return {
'vrp_optimization_savings': route.vrp_optimization_savings,
'vrp_algorithm_version': route.vrp_algorithm_version,
'vrp_optimization_timestamp': route.vrp_optimization_timestamp,
'vrp_constraints_satisfied': route.vrp_constraints_satisfied,
'vrp_objective_value': route.vrp_objective_value
}
async def get_network_optimization_summary(
self,
tenant_id: str
) -> Dict[str, Any]:
"""
Get VRP optimization summary across all routes for a tenant
Args:
tenant_id: Tenant ID
Returns:
Network optimization summary
"""
routes = await self.route_repository.get_routes_by_tenant(tenant_id)
total_optimized = 0
total_distance_saved = 0.0
total_time_saved = 0.0
total_cost_saved = 0.0
for route in routes:
if route.vrp_optimization_timestamp:
total_optimized += 1
savings = route.vrp_optimization_savings or {}
total_distance_saved += savings.get('distance_saved_km', 0.0)
total_time_saved += savings.get('time_saved_minutes', 0.0)
total_cost_saved += savings.get('cost_saved', 0.0)
return {
'total_routes': len(routes),
'total_optimized_routes': total_optimized,
'optimization_rate': total_optimized / len(routes) if routes else 0.0,
'total_distance_saved_km': total_distance_saved,
'total_time_saved_minutes': total_time_saved,
'total_cost_saved': total_cost_saved,
'average_savings_per_route': {
'distance_km': total_distance_saved / total_optimized if total_optimized > 0 else 0.0,
'time_minutes': total_time_saved / total_optimized if total_optimized > 0 else 0.0,
'cost': total_cost_saved / total_optimized if total_optimized > 0 else 0.0
}
}
async def batch_optimize_routes(
self,
tenant_id: str,
route_ids: List[str],
optimization_params: Dict[str, Any]
) -> Dict[str, Any]:
"""
Batch optimize multiple routes
Args:
tenant_id: Tenant ID
route_ids: List of route IDs to optimize
optimization_params: Optimization parameters
Returns:
Batch optimization results
"""
results = []
for route_id in route_ids:
try:
result = await self.optimize_route(tenant_id, route_id, optimization_params)
results.append({
'route_id': route_id,
'success': True,
'metrics': result['optimization_metrics']
})
except Exception as e:
results.append({
'route_id': route_id,
'success': False,
'error': str(e)
})
return {
'total_routes': len(route_ids),
'successful_optimizations': sum(1 for r in results if r['success']),
'failed_optimizations': sum(1 for r in results if not r['success']),
'results': results
}
async def validate_optimization_constraints(
self,
tenant_id: str,
route_id: str
) -> Dict[str, Any]:
"""
Validate VRP optimization constraints for a route
Args:
tenant_id: Tenant ID
route_id: Route ID
Returns:
Constraint validation results
"""
route = await self.route_repository.get_route_by_id(route_id)
if not route:
raise ValueError(f"Route {route_id} not found")
# Check if route has been optimized
if not route.vrp_optimization_timestamp:
return {
'route_id': route_id,
'is_optimized': False,
'constraints_valid': False,
'message': 'Route has not been optimized yet'
}
# Validate constraints
constraints_valid = route.vrp_constraints_satisfied or False
return {
'route_id': route_id,
'is_optimized': True,
'constraints_valid': constraints_valid,
'vrp_algorithm_version': route.vrp_algorithm_version,
'optimization_timestamp': route.vrp_optimization_timestamp
}
async def get_optimization_history(
self,
tenant_id: str,
limit: int = 50,
offset: int = 0
) -> Dict[str, Any]:
"""
Get VRP optimization history for a tenant
Args:
tenant_id: Tenant ID
limit: Maximum number of records to return
offset: Pagination offset
Returns:
Optimization history
"""
routes = await self.route_repository.get_routes_by_tenant(
tenant_id,
limit=limit,
offset=offset,
order_by='vrp_optimization_timestamp DESC'
)
history = []
for route in routes:
if route.vrp_optimization_timestamp:
history.append({
'route_id': str(route.id),
'route_number': route.route_number,
'optimization_timestamp': route.vrp_optimization_timestamp,
'algorithm_version': route.vrp_algorithm_version,
'constraints_satisfied': route.vrp_constraints_satisfied,
'objective_value': route.vrp_objective_value,
'savings': route.vrp_optimization_savings
})
return {
'total_records': len(history),
'history': history
}
async def simulate_optimization(
self,
tenant_id: str,
route_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Simulate VRP optimization without saving results
Args:
tenant_id: Tenant ID
route_data: Route data for simulation
Returns:
Simulation results
"""
try:
deliveries = route_data.get('deliveries', [])
depot_location = route_data.get('depot_location', (0.0, 0.0))
vehicle_capacity = route_data.get('vehicle_capacity_kg', 1000.0)
time_limit = route_data.get('time_limit_seconds', 30.0)
simulation_result = await self.routing_optimizer.optimize_daily_routes(
deliveries=deliveries,
depot_location=depot_location,
vehicle_capacity_kg=vehicle_capacity,
time_limit_seconds=time_limit
)
return {
'success': True,
'simulation_results': simulation_result,
'estimated_savings': {
'distance_km': simulation_result.get('distance_savings_km', 0.0),
'time_minutes': simulation_result.get('time_savings_minutes', 0.0),
'cost': simulation_result.get('cost_savings', 0.0)
}
}
except Exception as e:
logger.error("vrp_simulation_failed", error=str(e))
return {
'success': False,
'error': str(e)
}

View File

@@ -41,6 +41,12 @@ def upgrade():
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('updated_by', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('updated_by', postgresql.UUID(as_uuid=True), nullable=False),
# VRP Optimization Metrics
sa.Column('vrp_optimization_savings', sa.JSON(), nullable=True),
sa.Column('vrp_algorithm_version', sa.String(length=50), nullable=True),
sa.Column('vrp_optimization_timestamp', sa.DateTime(timezone=True), nullable=True),
sa.Column('vrp_constraints_satisfied', sa.Boolean(), nullable=True),
sa.Column('vrp_objective_value', sa.Float(), nullable=True),
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('route_number') sa.UniqueConstraint('route_number')
) )
@@ -53,6 +59,8 @@ def upgrade():
op.create_index('ix_delivery_routes_driver_id', 'delivery_routes', ['driver_id']) op.create_index('ix_delivery_routes_driver_id', 'delivery_routes', ['driver_id'])
op.create_index('ix_delivery_routes_tenant_date', 'delivery_routes', ['tenant_id', 'route_date']) op.create_index('ix_delivery_routes_tenant_date', 'delivery_routes', ['tenant_id', 'route_date'])
op.create_index('ix_delivery_routes_date_tenant_status', 'delivery_routes', ['route_date', 'tenant_id', 'status']) op.create_index('ix_delivery_routes_date_tenant_status', 'delivery_routes', ['route_date', 'tenant_id', 'status'])
# VRP Optimization Index
op.create_index('ix_delivery_routes_vrp_optimization', 'delivery_routes', ['vrp_optimization_timestamp'], unique=False)
# Create shipments table # Create shipments table
@@ -156,6 +164,7 @@ def downgrade():
op.drop_table('shipments') op.drop_table('shipments')
# Drop indexes for delivery_routes # Drop indexes for delivery_routes
op.drop_index('ix_delivery_routes_vrp_optimization', table_name='delivery_routes')
op.drop_index('ix_delivery_routes_date_tenant_status', table_name='delivery_routes') op.drop_index('ix_delivery_routes_date_tenant_status', table_name='delivery_routes')
op.drop_index('ix_delivery_routes_tenant_date', table_name='delivery_routes') op.drop_index('ix_delivery_routes_tenant_date', table_name='delivery_routes')
op.drop_index('ix_delivery_routes_driver_id', table_name='delivery_routes') op.drop_index('ix_delivery_routes_driver_id', table_name='delivery_routes')

View File

@@ -0,0 +1,417 @@
# services/forecasting/app/api/forecast_feedback.py
"""
Forecast Feedback API - Endpoints for collecting and analyzing forecast feedback
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Body
from typing import List, Optional, Dict, Any
from datetime import date, datetime
import uuid
import enum
from pydantic import BaseModel, Field
from app.services.forecast_feedback_service import ForecastFeedbackService
from shared.database.base import create_database_manager
from app.core.config import settings
from shared.routing import RouteBuilder
from shared.auth.tenant_access import verify_tenant_permission_dep
route_builder = RouteBuilder('forecasting')
logger = structlog.get_logger()
router = APIRouter(tags=["forecast-feedback"])
# Enums for feedback types
class FeedbackType(str, enum.Enum):
"""Type of feedback on forecast accuracy"""
TOO_HIGH = "too_high"
TOO_LOW = "too_low"
ACCURATE = "accurate"
UNCERTAIN = "uncertain"
class FeedbackConfidence(str, enum.Enum):
"""Confidence level of the feedback provider"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
# Pydantic models
from pydantic import BaseModel, Field
class ForecastFeedbackRequest(BaseModel):
"""Request model for submitting forecast feedback"""
feedback_type: FeedbackType = Field(..., description="Type of feedback on forecast accuracy")
confidence: FeedbackConfidence = Field(..., description="Confidence level of the feedback provider")
actual_value: Optional[float] = Field(None, description="Actual observed value")
notes: Optional[str] = Field(None, description="Additional notes about the feedback")
feedback_data: Optional[Dict[str, Any]] = Field(None, description="Additional feedback data")
class ForecastFeedbackResponse(BaseModel):
"""Response model for forecast feedback"""
feedback_id: str = Field(..., description="Unique feedback ID")
forecast_id: str = Field(..., description="Forecast ID this feedback relates to")
tenant_id: str = Field(..., description="Tenant ID")
feedback_type: FeedbackType = Field(..., description="Type of feedback")
confidence: FeedbackConfidence = Field(..., description="Confidence level")
actual_value: Optional[float] = Field(None, description="Actual value observed")
notes: Optional[str] = Field(None, description="Feedback notes")
feedback_data: Dict[str, Any] = Field(..., description="Additional feedback data")
created_at: datetime = Field(..., description="When feedback was created")
created_by: Optional[str] = Field(None, description="Who created the feedback")
class ForecastAccuracyMetrics(BaseModel):
"""Accuracy metrics for a forecast"""
forecast_id: str = Field(..., description="Forecast ID")
total_feedback_count: int = Field(..., description="Total feedback received")
accuracy_score: float = Field(..., description="Calculated accuracy score (0-100)")
feedback_distribution: Dict[str, int] = Field(..., description="Distribution of feedback types")
average_confidence: float = Field(..., description="Average confidence score")
last_feedback_date: Optional[datetime] = Field(None, description="Most recent feedback date")
class ForecasterPerformanceMetrics(BaseModel):
"""Performance metrics for the forecasting system"""
overall_accuracy: float = Field(..., description="Overall system accuracy score")
total_forecasts_with_feedback: int = Field(..., description="Total forecasts with feedback")
accuracy_by_product: Dict[str, float] = Field(..., description="Accuracy by product type")
accuracy_trend: str = Field(..., description="Trend direction: improving, declining, stable")
improvement_suggestions: List[str] = Field(..., description="AI-generated improvement suggestions")
def get_forecast_feedback_service():
"""Dependency injection for ForecastFeedbackService"""
database_manager = create_database_manager(settings.DATABASE_URL, "forecasting-service")
return ForecastFeedbackService(database_manager)
@router.post(
route_builder.build_nested_resource_route("forecasts", "forecast_id", "feedback"),
response_model=ForecastFeedbackResponse,
status_code=status.HTTP_201_CREATED
)
async def submit_forecast_feedback(
tenant_id: str = Path(..., description="Tenant ID"),
forecast_id: str = Path(..., description="Forecast ID"),
feedback_request: ForecastFeedbackRequest = Body(...),
forecast_feedback_service: ForecastFeedbackService = Depends(get_forecast_feedback_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Submit feedback on forecast accuracy
Allows users to provide feedback on whether forecasts were accurate, too high, or too low.
This feedback is used to improve future forecast accuracy through continuous learning.
"""
try:
logger.info("Submitting forecast feedback",
tenant_id=tenant_id, forecast_id=forecast_id,
feedback_type=feedback_request.feedback_type)
# Validate forecast exists
forecast_exists = await forecast_feedback_service.forecast_exists(tenant_id, forecast_id)
if not forecast_exists:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Forecast not found"
)
# Submit feedback
feedback = await forecast_feedback_service.submit_feedback(
tenant_id=tenant_id,
forecast_id=forecast_id,
feedback_type=feedback_request.feedback_type,
confidence=feedback_request.confidence,
actual_value=feedback_request.actual_value,
notes=feedback_request.notes,
feedback_data=feedback_request.feedback_data
)
return {
'feedback_id': str(feedback.feedback_id),
'forecast_id': str(feedback.forecast_id),
'tenant_id': feedback.tenant_id,
'feedback_type': feedback.feedback_type,
'confidence': feedback.confidence,
'actual_value': feedback.actual_value,
'notes': feedback.notes,
'feedback_data': feedback.feedback_data or {},
'created_at': feedback.created_at,
'created_by': feedback.created_by
}
except HTTPException:
raise
except ValueError as e:
logger.error("Invalid forecast ID", error=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid forecast ID format"
)
except Exception as e:
logger.error("Failed to submit forecast feedback", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to submit feedback"
)
@router.get(
route_builder.build_nested_resource_route("forecasts", "forecast_id", "feedback"),
response_model=List[ForecastFeedbackResponse]
)
async def get_forecast_feedback(
tenant_id: str = Path(..., description="Tenant ID"),
forecast_id: str = Path(..., description="Forecast ID"),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
forecast_feedback_service: ForecastFeedbackService = Depends(get_forecast_feedback_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get all feedback for a specific forecast
Retrieves historical feedback submissions for analysis and auditing.
"""
try:
logger.info("Getting forecast feedback", tenant_id=tenant_id, forecast_id=forecast_id)
feedback_list = await forecast_feedback_service.get_feedback_for_forecast(
tenant_id=tenant_id,
forecast_id=forecast_id,
limit=limit,
offset=offset
)
return [
ForecastFeedbackResponse(
feedback_id=str(f.feedback_id),
forecast_id=str(f.forecast_id),
tenant_id=f.tenant_id,
feedback_type=f.feedback_type,
confidence=f.confidence,
actual_value=f.actual_value,
notes=f.notes,
feedback_data=f.feedback_data or {},
created_at=f.created_at,
created_by=f.created_by
) for f in feedback_list
]
except Exception as e:
logger.error("Failed to get forecast feedback", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve feedback"
)
@router.get(
route_builder.build_nested_resource_route("forecasts", "forecast_id", "accuracy"),
response_model=ForecastAccuracyMetrics
)
async def get_forecast_accuracy_metrics(
tenant_id: str = Path(..., description="Tenant ID"),
forecast_id: str = Path(..., description="Forecast ID"),
forecast_feedback_service: ForecastFeedbackService = Depends(get_forecast_feedback_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get accuracy metrics for a specific forecast
Calculates accuracy scores based on feedback and actual vs predicted values.
"""
try:
logger.info("Getting forecast accuracy metrics", tenant_id=tenant_id, forecast_id=forecast_id)
metrics = await forecast_feedback_service.calculate_accuracy_metrics(
tenant_id=tenant_id,
forecast_id=forecast_id
)
if not metrics:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No accuracy metrics available for this forecast"
)
return {
'forecast_id': metrics.forecast_id,
'total_feedback_count': metrics.total_feedback_count,
'accuracy_score': metrics.accuracy_score,
'feedback_distribution': metrics.feedback_distribution,
'average_confidence': metrics.average_confidence,
'last_feedback_date': metrics.last_feedback_date
}
except Exception as e:
logger.error("Failed to get forecast accuracy metrics", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to calculate accuracy metrics"
)
@router.get(
route_builder.build_base_route("forecasts", "accuracy-summary"),
response_model=ForecasterPerformanceMetrics
)
async def get_forecaster_performance_summary(
tenant_id: str = Path(..., description="Tenant ID"),
start_date: Optional[date] = Query(None, description="Start date filter"),
end_date: Optional[date] = Query(None, description="End date filter"),
product_id: Optional[str] = Query(None, description="Filter by product ID"),
forecast_feedback_service: ForecastFeedbackService = Depends(get_forecast_feedback_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get overall forecaster performance summary
Aggregates accuracy metrics across all forecasts to assess overall system performance
and identify areas for improvement.
"""
try:
logger.info("Getting forecaster performance summary", tenant_id=tenant_id)
metrics = await forecast_feedback_service.calculate_performance_summary(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date,
product_id=product_id
)
return {
'overall_accuracy': metrics.overall_accuracy,
'total_forecasts_with_feedback': metrics.total_forecasts_with_feedback,
'accuracy_by_product': metrics.accuracy_by_product,
'accuracy_trend': metrics.accuracy_trend,
'improvement_suggestions': metrics.improvement_suggestions
}
except Exception as e:
logger.error("Failed to get forecaster performance summary", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to calculate performance summary"
)
@router.get(
route_builder.build_base_route("forecasts", "feedback-trends")
)
async def get_feedback_trends(
tenant_id: str = Path(..., description="Tenant ID"),
days: int = Query(30, ge=7, le=365, description="Number of days to analyze"),
product_id: Optional[str] = Query(None, description="Filter by product ID"),
forecast_feedback_service: ForecastFeedbackService = Depends(get_forecast_feedback_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get feedback trends over time
Analyzes how forecast accuracy and feedback patterns change over time.
"""
try:
logger.info("Getting feedback trends", tenant_id=tenant_id, days=days)
trends = await forecast_feedback_service.get_feedback_trends(
tenant_id=tenant_id,
days=days,
product_id=product_id
)
return {
'success': True,
'trends': trends,
'period': f'Last {days} days'
}
except Exception as e:
logger.error("Failed to get feedback trends", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve feedback trends"
)
@router.post(
route_builder.build_resource_action_route("forecasts", "forecast_id", "retrain")
)
async def trigger_retraining_from_feedback(
tenant_id: str = Path(..., description="Tenant ID"),
forecast_id: str = Path(..., description="Forecast ID"),
forecast_feedback_service: ForecastFeedbackService = Depends(get_forecast_feedback_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Trigger model retraining based on feedback
Initiates a retraining job using recent feedback to improve forecast accuracy.
"""
try:
logger.info("Triggering retraining from feedback", tenant_id=tenant_id, forecast_id=forecast_id)
result = await forecast_feedback_service.trigger_retraining_from_feedback(
tenant_id=tenant_id,
forecast_id=forecast_id
)
return {
'success': True,
'message': 'Retraining job initiated successfully',
'job_id': result.job_id,
'forecasts_included': result.forecasts_included,
'feedback_samples_used': result.feedback_samples_used
}
except Exception as e:
logger.error("Failed to trigger retraining", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to initiate retraining"
)
@router.get(
route_builder.build_resource_action_route("forecasts", "forecast_id", "suggestions")
)
async def get_improvement_suggestions(
tenant_id: str = Path(..., description="Tenant ID"),
forecast_id: str = Path(..., description="Forecast ID"),
forecast_feedback_service: ForecastFeedbackService = Depends(get_forecast_feedback_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get AI-generated improvement suggestions for a forecast
Analyzes feedback patterns and suggests specific improvements for forecast accuracy.
"""
try:
logger.info("Getting improvement suggestions", tenant_id=tenant_id, forecast_id=forecast_id)
suggestions = await forecast_feedback_service.get_improvement_suggestions(
tenant_id=tenant_id,
forecast_id=forecast_id
)
return {
'success': True,
'forecast_id': forecast_id,
'suggestions': suggestions,
'confidence_scores': [s.get('confidence', 0.8) for s in suggestions]
}
except Exception as e:
logger.error("Failed to get improvement suggestions", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate suggestions"
)
# Import datetime at runtime to avoid circular imports
from datetime import datetime, timedelta

View File

@@ -14,7 +14,7 @@ from app.services.forecasting_alert_service import ForecastingAlertService
from shared.service_base import StandardFastAPIService from shared.service_base import StandardFastAPIService
# Import API routers # Import API routers
from app.api import forecasts, forecasting_operations, analytics, scenario_operations, audit, ml_insights, validation, historical_validation, webhooks, performance_monitoring, retraining, enterprise_forecasting, internal_demo from app.api import forecasts, forecasting_operations, analytics, scenario_operations, audit, ml_insights, validation, historical_validation, webhooks, performance_monitoring, retraining, enterprise_forecasting, internal_demo, forecast_feedback
class ForecastingService(StandardFastAPIService): class ForecastingService(StandardFastAPIService):
@@ -200,6 +200,7 @@ service.add_router(webhooks.router) # Webhooks endpoint
service.add_router(performance_monitoring.router) # Performance monitoring endpoint service.add_router(performance_monitoring.router) # Performance monitoring endpoint
service.add_router(retraining.router) # Retraining endpoint service.add_router(retraining.router) # Retraining endpoint
service.add_router(enterprise_forecasting.router) # Enterprise forecasting endpoint service.add_router(enterprise_forecasting.router) # Enterprise forecasting endpoint
service.add_router(forecast_feedback.router) # Forecast feedback endpoint
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -0,0 +1,533 @@
# services/forecasting/app/services/forecast_feedback_service.py
"""
Forecast Feedback Service
Business logic for collecting and analyzing forecast feedback
"""
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta, date
import uuid
import structlog
from dataclasses import dataclass
logger = structlog.get_logger()
@dataclass
class ForecastFeedback:
"""Data class for forecast feedback"""
feedback_id: uuid.UUID
forecast_id: uuid.UUID
tenant_id: str
feedback_type: str
confidence: str
actual_value: Optional[float]
notes: Optional[str]
feedback_data: Dict[str, Any]
created_at: datetime
created_by: Optional[str]
@dataclass
class ForecastAccuracyMetrics:
"""Data class for forecast accuracy metrics"""
forecast_id: str
total_feedback_count: int
accuracy_score: float
feedback_distribution: Dict[str, int]
average_confidence: float
last_feedback_date: Optional[datetime]
@dataclass
class ForecasterPerformanceMetrics:
"""Data class for forecaster performance metrics"""
overall_accuracy: float
total_forecasts_with_feedback: int
accuracy_by_product: Dict[str, float]
accuracy_trend: str
improvement_suggestions: List[str]
class ForecastFeedbackService:
"""
Service for managing forecast feedback and accuracy tracking
"""
def __init__(self, database_manager):
self.database_manager = database_manager
async def forecast_exists(self, tenant_id: str, forecast_id: str) -> bool:
"""
Check if a forecast exists
"""
try:
async with self.database_manager.get_session() as session:
from app.models.forecasts import Forecast
result = await session.execute(
"""
SELECT 1 FROM forecasts
WHERE tenant_id = :tenant_id AND id = :forecast_id
""",
{"tenant_id": tenant_id, "forecast_id": forecast_id}
)
return result.scalar() is not None
except Exception as e:
logger.error("Failed to check forecast existence", error=str(e))
raise Exception(f"Failed to check forecast existence: {str(e)}")
async def submit_feedback(
self,
tenant_id: str,
forecast_id: str,
feedback_type: str,
confidence: str,
actual_value: Optional[float] = None,
notes: Optional[str] = None,
feedback_data: Optional[Dict[str, Any]] = None
) -> ForecastFeedback:
"""
Submit feedback on forecast accuracy
"""
try:
async with self.database_manager.get_session() as session:
# Create feedback record
feedback_id = uuid.uuid4()
created_at = datetime.now()
# In a real implementation, this would insert into a forecast_feedback table
# For demo purposes, we'll simulate the database operation
feedback = ForecastFeedback(
feedback_id=feedback_id,
forecast_id=uuid.UUID(forecast_id),
tenant_id=tenant_id,
feedback_type=feedback_type,
confidence=confidence,
actual_value=actual_value,
notes=notes,
feedback_data=feedback_data or {},
created_at=created_at,
created_by="system" # In real implementation, this would be the user ID
)
# Simulate database insert
logger.info("Feedback submitted",
feedback_id=str(feedback_id),
forecast_id=forecast_id,
feedback_type=feedback_type)
return feedback
except Exception as e:
logger.error("Failed to submit feedback", error=str(e))
raise Exception(f"Failed to submit feedback: {str(e)}")
async def get_feedback_for_forecast(
self,
tenant_id: str,
forecast_id: str,
limit: int = 50,
offset: int = 0
) -> List[ForecastFeedback]:
"""
Get all feedback for a specific forecast
"""
try:
# In a real implementation, this would query the forecast_feedback table
# For demo purposes, we'll return simulated data
# Simulate some feedback data
simulated_feedback = []
for i in range(min(limit, 3)): # Return up to 3 simulated feedback items
feedback = ForecastFeedback(
feedback_id=uuid.uuid4(),
forecast_id=uuid.UUID(forecast_id),
tenant_id=tenant_id,
feedback_type=["too_high", "too_low", "accurate"][i % 3],
confidence=["medium", "high", "low"][i % 3],
actual_value=150.0 + i * 20 if i < 2 else None,
notes=f"Feedback sample {i+1}" if i == 0 else None,
feedback_data={"sample": i+1, "demo": True},
created_at=datetime.now() - timedelta(days=i),
created_by="demo_user"
)
simulated_feedback.append(feedback)
return simulated_feedback
except Exception as e:
logger.error("Failed to get feedback for forecast", error=str(e))
raise Exception(f"Failed to get feedback: {str(e)}")
async def calculate_accuracy_metrics(
self,
tenant_id: str,
forecast_id: str
) -> ForecastAccuracyMetrics:
"""
Calculate accuracy metrics for a forecast
"""
try:
# Get feedback for this forecast
feedback_list = await self.get_feedback_for_forecast(tenant_id, forecast_id)
if not feedback_list:
return None
# Calculate metrics
total_feedback = len(feedback_list)
# Count feedback distribution
feedback_distribution = {
"too_high": 0,
"too_low": 0,
"accurate": 0,
"uncertain": 0
}
confidence_scores = {
"low": 1,
"medium": 2,
"high": 3
}
total_confidence = 0
for feedback in feedback_list:
feedback_distribution[feedback.feedback_type] += 1
total_confidence += confidence_scores.get(feedback.confidence, 1)
# Calculate accuracy score (simplified)
accurate_count = feedback_distribution["accurate"]
accuracy_score = (accurate_count / total_feedback) * 100
# Adjust for confidence
avg_confidence = total_confidence / total_feedback
adjusted_accuracy = accuracy_score * (avg_confidence / 3) # Normalize confidence to 0-1 range
return ForecastAccuracyMetrics(
forecast_id=forecast_id,
total_feedback_count=total_feedback,
accuracy_score=round(adjusted_accuracy, 1),
feedback_distribution=feedback_distribution,
average_confidence=round(avg_confidence, 1),
last_feedback_date=max(f.created_at for f in feedback_list)
)
except Exception as e:
logger.error("Failed to calculate accuracy metrics", error=str(e))
raise Exception(f"Failed to calculate metrics: {str(e)}")
async def calculate_performance_summary(
self,
tenant_id: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
product_id: Optional[str] = None
) -> ForecasterPerformanceMetrics:
"""
Calculate overall forecaster performance summary
"""
try:
# In a real implementation, this would aggregate data across multiple forecasts
# For demo purposes, we'll return simulated metrics
# Simulate performance data
accuracy_by_product = {
"baguette": 85.5,
"croissant": 78.2,
"pain_au_chocolat": 92.1
}
if product_id and product_id in accuracy_by_product:
# Return metrics for specific product
product_accuracy = accuracy_by_product[product_id]
accuracy_by_product = {product_id: product_accuracy}
# Calculate overall accuracy
overall_accuracy = sum(accuracy_by_product.values()) / len(accuracy_by_product)
# Determine trend (simulated)
trend_data = [82.3, 84.1, 85.5, 86.8, 88.2] # Last 5 periods
if trend_data[-1] > trend_data[0]:
trend = "improving"
elif trend_data[-1] < trend_data[0]:
trend = "declining"
else:
trend = "stable"
# Generate improvement suggestions
suggestions = []
for product, accuracy in accuracy_by_product.items():
if accuracy < 80:
suggestions.append(f"Improve {product} forecast accuracy (current: {accuracy}%)")
elif accuracy < 90:
suggestions.append(f"Consider fine-tuning {product} forecast model (current: {accuracy}%)")
if not suggestions:
suggestions.append("Overall forecast accuracy is excellent - maintain current approach")
return ForecasterPerformanceMetrics(
overall_accuracy=round(overall_accuracy, 1),
total_forecasts_with_feedback=42,
accuracy_by_product=accuracy_by_product,
accuracy_trend=trend,
improvement_suggestions=suggestions
)
except Exception as e:
logger.error("Failed to calculate performance summary", error=str(e))
raise Exception(f"Failed to calculate summary: {str(e)}")
async def get_feedback_trends(
self,
tenant_id: str,
days: int = 30,
product_id: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Get feedback trends over time
"""
try:
# Simulate trend data
trends = []
end_date = datetime.now()
# Generate daily trend data
for i in range(days):
date = end_date - timedelta(days=i)
# Simulate varying accuracy with weekly pattern
base_accuracy = 85.0
weekly_variation = 3.0 * (i % 7 / 6 - 0.5) # Weekly pattern
daily_noise = (i % 3 - 1) * 1.5 # Daily noise
accuracy = max(70, min(95, base_accuracy + weekly_variation + daily_noise))
trends.append({
'date': date.strftime('%Y-%m-%d'),
'accuracy_score': round(accuracy, 1),
'feedback_count': max(1, int(5 + i % 10)),
'confidence_score': round(2.5 + (i % 5 - 2) * 0.2, 1)
})
# Sort by date (oldest first)
trends.sort(key=lambda x: x['date'])
return trends
except Exception as e:
logger.error("Failed to get feedback trends", error=str(e))
raise Exception(f"Failed to get trends: {str(e)}")
async def trigger_retraining_from_feedback(
self,
tenant_id: str,
forecast_id: str
) -> Dict[str, Any]:
"""
Trigger model retraining based on feedback
"""
try:
# In a real implementation, this would:
# 1. Collect recent feedback data
# 2. Prepare training dataset
# 3. Submit retraining job to ML service
# 4. Return job ID
# For demo purposes, simulate a retraining job
job_id = str(uuid.uuid4())
logger.info("Retraining job triggered",
job_id=job_id,
tenant_id=tenant_id,
forecast_id=forecast_id)
return {
'job_id': job_id,
'forecasts_included': 15,
'feedback_samples_used': 42,
'status': 'queued',
'estimated_completion': (datetime.now() + timedelta(minutes=30)).isoformat()
}
except Exception as e:
logger.error("Failed to trigger retraining", error=str(e))
raise Exception(f"Failed to trigger retraining: {str(e)}")
async def get_improvement_suggestions(
self,
tenant_id: str,
forecast_id: str
) -> List[Dict[str, Any]]:
"""
Get AI-generated improvement suggestions
"""
try:
# Get accuracy metrics for this forecast
metrics = await self.calculate_accuracy_metrics(tenant_id, forecast_id)
if not metrics:
return [
{
'suggestion': 'Insufficient feedback data to generate suggestions',
'type': 'data',
'priority': 'low',
'confidence': 0.7
}
]
# Generate suggestions based on metrics
suggestions = []
# Analyze feedback distribution
feedback_dist = metrics.feedback_distribution
total_feedback = metrics.total_feedback_count
if feedback_dist['too_high'] > total_feedback * 0.4:
suggestions.append({
'suggestion': 'Forecasts are consistently too high - consider adjusting demand estimation parameters',
'type': 'bias',
'priority': 'high',
'confidence': 0.9,
'details': {
'too_high_percentage': feedback_dist['too_high'] / total_feedback * 100,
'recommended_action': 'Reduce demand estimation by 10-15%'
}
})
if feedback_dist['too_low'] > total_feedback * 0.4:
suggestions.append({
'suggestion': 'Forecasts are consistently too low - consider increasing demand estimation parameters',
'type': 'bias',
'priority': 'high',
'confidence': 0.9,
'details': {
'too_low_percentage': feedback_dist['too_low'] / total_feedback * 100,
'recommended_action': 'Increase demand estimation by 10-15%'
}
})
if metrics.accuracy_score < 70:
suggestions.append({
'suggestion': 'Low overall accuracy - consider comprehensive model review and retraining',
'type': 'model',
'priority': 'critical',
'confidence': 0.85,
'details': {
'current_accuracy': metrics.accuracy_score,
'recommended_action': 'Full model retraining with expanded feature set'
}
})
elif metrics.accuracy_score < 85:
suggestions.append({
'suggestion': 'Moderate accuracy - consider feature engineering improvements',
'type': 'features',
'priority': 'medium',
'confidence': 0.8,
'details': {
'current_accuracy': metrics.accuracy_score,
'recommended_action': 'Add weather data, promotions, and seasonal features'
}
})
if metrics.average_confidence < 2.0: # Average of medium (2) and high (3)
suggestions.append({
'suggestion': 'Low confidence in feedback - consider improving feedback collection process',
'type': 'process',
'priority': 'medium',
'confidence': 0.75,
'details': {
'average_confidence': metrics.average_confidence,
'recommended_action': 'Provide clearer guidance to users on feedback submission'
}
})
if not suggestions:
suggestions.append({
'suggestion': 'Forecast accuracy is good - consider expanding to additional products',
'type': 'expansion',
'priority': 'low',
'confidence': 0.85,
'details': {
'current_accuracy': metrics.accuracy_score,
'recommended_action': 'Extend forecasting to new product categories'
}
})
return suggestions
except Exception as e:
logger.error("Failed to generate improvement suggestions", error=str(e))
raise Exception(f"Failed to generate suggestions: {str(e)}")
# Helper class for feedback analysis
class FeedbackAnalyzer:
"""
Helper class for analyzing feedback patterns
"""
@staticmethod
def detect_feedback_patterns(feedback_list: List[ForecastFeedback]) -> Dict[str, Any]:
"""
Detect patterns in feedback data
"""
if not feedback_list:
return {'patterns': [], 'anomalies': []}
patterns = []
anomalies = []
# Simple pattern detection (in real implementation, this would be more sophisticated)
feedback_types = [f.feedback_type for f in feedback_list]
if len(set(feedback_types)) == 1:
patterns.append({
'type': 'consistent_feedback',
'pattern': f'All feedback is "{feedback_types[0]}"',
'confidence': 0.9
})
return {'patterns': patterns, 'anomalies': anomalies}
# Helper class for accuracy calculation
class AccuracyCalculator:
"""
Helper class for calculating forecast accuracy metrics
"""
@staticmethod
def calculate_mape(actual: float, predicted: float) -> float:
"""
Calculate Mean Absolute Percentage Error
"""
if actual == 0:
return 0.0
return abs((actual - predicted) / actual) * 100
@staticmethod
def calculate_rmse(actual: float, predicted: float) -> float:
"""
Calculate Root Mean Squared Error
"""
return (actual - predicted) ** 2
@staticmethod
def feedback_to_accuracy_score(feedback_type: str) -> float:
"""
Convert feedback type to accuracy score
"""
feedback_scores = {
'accurate': 100,
'too_high': 50,
'too_low': 50,
'uncertain': 75
}
return feedback_scores.get(feedback_type, 75)

View File

@@ -0,0 +1,314 @@
"""
Enterprise Inventory API Endpoints
APIs for enterprise-level inventory management across outlets
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from datetime import date
from pydantic import BaseModel, Field
import structlog
from app.services.enterprise_inventory_service import EnterpriseInventoryService
from shared.auth.tenant_access import verify_tenant_permission_dep
from shared.clients import get_inventory_client, get_tenant_client
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter()
# Pydantic models for request/response
class InventoryCoverageResponse(BaseModel):
outlet_id: str = Field(..., description="Outlet tenant ID")
outlet_name: str = Field(..., description="Outlet name")
overall_coverage: float = Field(..., description="Overall inventory coverage percentage (0-100)")
critical_items_count: int = Field(..., description="Number of items at critical stock levels")
high_risk_items_count: int = Field(..., description="Number of items at high risk of stockout")
medium_risk_items_count: int = Field(..., description="Number of items at medium risk")
low_risk_items_count: int = Field(..., description="Number of items at low risk")
fulfillment_rate: float = Field(..., description="Order fulfillment rate percentage (0-100)")
last_updated: str = Field(..., description="Last inventory update timestamp")
status: str = Field(..., description="Overall status: normal, warning, critical")
class ProductCoverageDetail(BaseModel):
product_id: str = Field(..., description="Product ID")
product_name: str = Field(..., description="Product name")
current_stock: int = Field(..., description="Current stock quantity")
safety_stock: int = Field(..., description="Safety stock threshold")
coverage_percentage: float = Field(..., description="Coverage percentage (current/safety)")
risk_level: str = Field(..., description="Risk level: critical, high, medium, low")
days_until_stockout: Optional[int] = Field(None, description="Estimated days until stockout")
class OutletInventoryDetailResponse(BaseModel):
outlet_id: str = Field(..., description="Outlet tenant ID")
outlet_name: str = Field(..., description="Outlet name")
overall_coverage: float = Field(..., description="Overall inventory coverage percentage")
products: List[ProductCoverageDetail] = Field(..., description="Product-level inventory details")
last_updated: str = Field(..., description="Last update timestamp")
class NetworkInventorySummary(BaseModel):
total_outlets: int = Field(..., description="Total number of outlets")
average_coverage: float = Field(..., description="Network average inventory coverage")
average_fulfillment_rate: float = Field(..., description="Network average fulfillment rate")
critical_outlets: int = Field(..., description="Number of outlets with critical status")
warning_outlets: int = Field(..., description="Number of outlets with warning status")
normal_outlets: int = Field(..., description="Number of outlets with normal status")
total_critical_items: int = Field(..., description="Total critical items across network")
network_health_score: float = Field(..., description="Overall network health score (0-100)")
class InventoryAlert(BaseModel):
alert_id: str = Field(..., description="Alert ID")
outlet_id: str = Field(..., description="Outlet ID")
outlet_name: str = Field(..., description="Outlet name")
product_id: Optional[str] = Field(None, description="Product ID if applicable")
product_name: Optional[str] = Field(None, description="Product name if applicable")
alert_type: str = Field(..., description="Type of alert: stockout_risk, low_coverage, etc.")
severity: str = Field(..., description="Severity: critical, high, medium, low")
current_coverage: float = Field(..., description="Current inventory coverage percentage")
threshold: float = Field(..., description="Threshold that triggered alert")
timestamp: str = Field(..., description="Alert timestamp")
message: str = Field(..., description="Alert message")
async def get_enterprise_inventory_service() -> "EnterpriseInventoryService":
"""Dependency injection for EnterpriseInventoryService"""
inventory_client = get_inventory_client(settings, "inventory-service")
tenant_client = get_tenant_client(settings, "inventory-service")
return EnterpriseInventoryService(
inventory_client=inventory_client,
tenant_client=tenant_client
)
@router.get("/tenants/{parent_id}/outlets/inventory-coverage",
response_model=List[InventoryCoverageResponse],
summary="Get inventory coverage for all outlets in network")
async def get_outlet_inventory_coverage(
parent_id: str,
min_coverage: Optional[float] = Query(None, description="Filter outlets with coverage below this threshold"),
risk_level: Optional[str] = Query(None, description="Filter by risk level: critical, high, medium, low"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get inventory coverage metrics for all child outlets in a parent tenant's network
This endpoint provides a comprehensive view of inventory health across all outlets,
enabling enterprise managers to identify stockout risks and prioritize inventory transfers.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access outlet inventory coverage"
)
# Get all child outlets for this parent
child_outlets = await enterprise_inventory_service.get_child_outlets(parent_id)
if not child_outlets:
return []
# Get inventory coverage for each outlet
coverage_data = []
for outlet in child_outlets:
outlet_id = outlet['id']
# Get inventory coverage data
coverage = await enterprise_inventory_service.get_inventory_coverage(outlet_id)
if coverage:
# Apply filters if specified
if min_coverage is not None and coverage['overall_coverage'] >= min_coverage:
continue
if risk_level is not None and coverage.get('status') != risk_level:
continue
coverage_data.append(coverage)
# Sort by coverage (lowest first) to prioritize critical outlets
coverage_data.sort(key=lambda x: x['overall_coverage'])
return coverage_data
except Exception as e:
logger.error("Failed to get outlet inventory coverage", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get inventory coverage: {str(e)}")
@router.get("/tenants/{parent_id}/outlets/inventory-summary",
response_model=NetworkInventorySummary,
summary="Get network-wide inventory summary")
async def get_network_inventory_summary(
parent_id: str,
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get aggregated inventory summary across the entire network
Provides key metrics for network health monitoring and decision making.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access network inventory summary"
)
return await enterprise_inventory_service.get_network_inventory_summary(parent_id)
except Exception as e:
logger.error("Failed to get network inventory summary", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get inventory summary: {str(e)}")
@router.get("/tenants/{parent_id}/outlets/{outlet_id}/inventory-details",
response_model=OutletInventoryDetailResponse,
summary="Get detailed inventory for specific outlet")
async def get_outlet_inventory_details(
parent_id: str,
outlet_id: str,
product_id: Optional[str] = Query(None, description="Filter by specific product ID"),
risk_level: Optional[str] = Query(None, description="Filter products by risk level"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get detailed product-level inventory data for a specific outlet
Enables drill-down analysis of inventory issues at the product level.
"""
try:
# Verify parent-child relationship
await enterprise_inventory_service.verify_parent_child_relationship(parent_id, outlet_id)
return await enterprise_inventory_service.get_outlet_inventory_details(outlet_id, product_id, risk_level)
except Exception as e:
logger.error("Failed to get outlet inventory details", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get inventory details: {str(e)}")
@router.get("/tenants/{parent_id}/inventory-alerts",
response_model=List[InventoryAlert],
summary="Get real-time inventory alerts across network")
async def get_network_inventory_alerts(
parent_id: str,
severity: Optional[str] = Query(None, description="Filter by severity: critical, high, medium, low"),
alert_type: Optional[str] = Query(None, description="Filter by alert type"),
limit: int = Query(50, description="Maximum number of alerts to return"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get real-time inventory alerts across all outlets
Provides actionable alerts for inventory management and stockout prevention.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access network inventory alerts"
)
alerts = await enterprise_inventory_service.get_inventory_alerts(parent_id)
# Apply filters
if severity:
alerts = [alert for alert in alerts if alert.get('severity') == severity]
if alert_type:
alerts = [alert for alert in alerts if alert.get('alert_type') == alert_type]
# Sort by severity (critical first) and timestamp (newest first)
severity_order = {'critical': 1, 'high': 2, 'medium': 3, 'low': 4}
alerts.sort(key=lambda x: (severity_order.get(x.get('severity', 'low'), 5), -int(x.get('timestamp', 0))))
return alerts[:limit]
except Exception as e:
logger.error("Failed to get inventory alerts", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get inventory alerts: {str(e)}")
@router.post("/tenants/{parent_id}/inventory-transfers/recommend",
summary="Get inventory transfer recommendations")
async def get_inventory_transfer_recommendations(
parent_id: str,
urgency: str = Query("medium", description="Urgency level: low, medium, high, critical"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get AI-powered inventory transfer recommendations
Analyzes inventory levels across outlets and suggests optimal transfers
to prevent stockouts and balance inventory.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can request transfer recommendations"
)
recommendations = await enterprise_inventory_service.get_transfer_recommendations(parent_id, urgency)
return {
'success': True,
'recommendations': recommendations,
'message': f'Generated {len(recommendations)} transfer recommendations'
}
except Exception as e:
logger.error("Failed to get transfer recommendations", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get recommendations: {str(e)}")
@router.get("/tenants/{parent_id}/inventory/coverage-trends",
summary="Get inventory coverage trends over time")
async def get_inventory_coverage_trends(
parent_id: str,
days: int = Query(30, description="Number of days to analyze"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get historical inventory coverage trends
Enables analysis of inventory performance over time.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access coverage trends"
)
trends = await enterprise_inventory_service.get_coverage_trends(parent_id, days)
return {
'success': True,
'trends': trends,
'period': f'Last {days} days'
}
except Exception as e:
logger.error("Failed to get coverage trends", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get coverage trends: {str(e)}")

View File

@@ -32,7 +32,8 @@ from app.api import (
analytics, analytics,
sustainability, sustainability,
audit, audit,
ml_insights ml_insights,
enterprise_inventory
) )
from app.api.internal_alert_trigger import router as internal_alert_trigger_router from app.api.internal_alert_trigger import router as internal_alert_trigger_router
from app.api.internal_demo import router as internal_demo_router from app.api.internal_demo import router as internal_demo_router
@@ -217,6 +218,7 @@ service.add_router(internal_demo.router, tags=["internal-demo"])
service.add_router(ml_insights.router) # ML insights endpoint service.add_router(ml_insights.router) # ML insights endpoint
service.add_router(ml_insights.internal_router) # Internal ML insights endpoint for demo cloning service.add_router(ml_insights.internal_router) # Internal ML insights endpoint for demo cloning
service.add_router(internal_alert_trigger_router) # Internal alert trigger for demo cloning service.add_router(internal_alert_trigger_router) # Internal alert trigger for demo cloning
service.add_router(enterprise_inventory.router) # Enterprise inventory endpoints
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,473 @@
"""
Enterprise Inventory Service
Business logic for enterprise-level inventory management across outlets
"""
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
import uuid
import structlog
logger = structlog.get_logger()
class EnterpriseInventoryService:
"""
Service for managing inventory across enterprise networks
"""
def __init__(self, inventory_client, tenant_client):
self.inventory_client = inventory_client
self.tenant_client = tenant_client
async def get_child_outlets(self, parent_id: str) -> List[Dict[str, Any]]:
"""
Get all child outlets for a parent tenant
"""
try:
# Get child tenants from tenant service
children = await self.tenant_client.get_child_tenants(parent_id)
# Enrich with location data
enriched_outlets = []
for child in children:
# Get location data for this outlet
locations = await self.tenant_client.get_tenant_locations(child['id'])
outlet_info = {
'id': child['id'],
'name': child['name'],
'subdomain': child.get('subdomain'),
'location': locations[0] if locations else None
}
enriched_outlets.append(outlet_info)
return enriched_outlets
except Exception as e:
logger.error("Failed to get child outlets", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get child outlets: {str(e)}")
async def get_inventory_coverage(self, outlet_id: str) -> Dict[str, Any]:
"""
Get inventory coverage metrics for a specific outlet
"""
try:
# Get current inventory data
inventory_data = await self.inventory_client.get_current_inventory(outlet_id)
if not inventory_data or not inventory_data.get('items'):
return None
# Calculate coverage metrics
total_items = len(inventory_data['items'])
critical_count = 0
high_risk_count = 0
medium_risk_count = 0
low_risk_count = 0
total_coverage = 0
for item in inventory_data['items']:
current_stock = item.get('current_stock', 0)
safety_stock = item.get('safety_stock', 1) # Avoid division by zero
if safety_stock <= 0:
safety_stock = 1
coverage = min(100, (current_stock / safety_stock) * 100)
total_coverage += coverage
# Determine risk level
if coverage < 30:
critical_count += 1
elif coverage < 50:
high_risk_count += 1
elif coverage < 70:
medium_risk_count += 1
else:
low_risk_count += 1
# Calculate average coverage
avg_coverage = total_coverage / total_items if total_items > 0 else 0
# Get fulfillment rate (simplified - in real implementation this would come from orders service)
fulfillment_rate = await self._calculate_fulfillment_rate(outlet_id)
# Determine overall status
status = self._determine_inventory_status(critical_count, high_risk_count, avg_coverage)
return {
'outlet_id': outlet_id,
'outlet_name': inventory_data.get('tenant_name', f'Outlet {outlet_id}'),
'overall_coverage': round(avg_coverage, 1),
'critical_items_count': critical_count,
'high_risk_items_count': high_risk_count,
'medium_risk_items_count': medium_risk_count,
'low_risk_items_count': low_risk_count,
'fulfillment_rate': round(fulfillment_rate, 1),
'last_updated': datetime.now().isoformat(),
'status': status
}
except Exception as e:
logger.error("Failed to get inventory coverage", outlet_id=outlet_id, error=str(e))
raise Exception(f"Failed to get inventory coverage: {str(e)}")
async def _calculate_fulfillment_rate(self, outlet_id: str) -> float:
"""
Calculate fulfillment rate for an outlet (simplified)
In a real implementation, this would query the orders service
"""
# This is a placeholder - real implementation would:
# 1. Get recent orders from orders service
# 2. Calculate % successfully fulfilled
# 3. Return the rate
# For demo purposes, return a reasonable default
return 95.0
def _determine_inventory_status(self, critical_count: int, high_risk_count: int, avg_coverage: float) -> str:
"""
Determine overall inventory status based on risk factors
"""
if critical_count > 5 or (critical_count > 0 and avg_coverage < 40):
return 'critical'
elif high_risk_count > 3 or (high_risk_count > 0 and avg_coverage < 60):
return 'warning'
else:
return 'normal'
async def get_network_inventory_summary(self, parent_id: str) -> Dict[str, Any]:
"""
Get aggregated inventory summary across the entire network
"""
try:
# Get all child outlets
child_outlets = await self.get_child_outlets(parent_id)
if not child_outlets:
return {
'total_outlets': 0,
'average_coverage': 0,
'average_fulfillment_rate': 0,
'critical_outlets': 0,
'warning_outlets': 0,
'normal_outlets': 0,
'total_critical_items': 0,
'network_health_score': 0
}
# Get coverage for each outlet
coverage_data = []
for outlet in child_outlets:
coverage = await self.get_inventory_coverage(outlet['id'])
if coverage:
coverage_data.append(coverage)
if not coverage_data:
return {
'total_outlets': len(child_outlets),
'average_coverage': 0,
'average_fulfillment_rate': 0,
'critical_outlets': 0,
'warning_outlets': 0,
'normal_outlets': len(child_outlets),
'total_critical_items': 0,
'network_health_score': 0
}
# Calculate network metrics
total_coverage = sum(c['overall_coverage'] for c in coverage_data)
total_fulfillment = sum(c['fulfillment_rate'] for c in coverage_data)
avg_coverage = total_coverage / len(coverage_data)
avg_fulfillment = total_fulfillment / len(coverage_data)
critical_outlets = sum(1 for c in coverage_data if c['status'] == 'critical')
warning_outlets = sum(1 for c in coverage_data if c['status'] == 'warning')
normal_outlets = sum(1 for c in coverage_data if c['status'] == 'normal')
total_critical_items = sum(c['critical_items_count'] for c in coverage_data)
# Calculate network health score (weighted average)
network_health = round(avg_coverage * 0.6 + avg_fulfillment * 0.4, 1)
return {
'total_outlets': len(child_outlets),
'average_coverage': round(avg_coverage, 1),
'average_fulfillment_rate': round(avg_fulfillment, 1),
'critical_outlets': critical_outlets,
'warning_outlets': warning_outlets,
'normal_outlets': normal_outlets,
'total_critical_items': total_critical_items,
'network_health_score': network_health
}
except Exception as e:
logger.error("Failed to get network inventory summary", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get network inventory summary: {str(e)}")
async def get_outlet_inventory_details(self, outlet_id: str, product_id: Optional[str] = None, risk_level: Optional[str] = None) -> Dict[str, Any]:
"""
Get detailed product-level inventory data for a specific outlet
"""
try:
# Get current inventory data
inventory_data = await self.inventory_client.get_current_inventory(outlet_id)
if not inventory_data or not inventory_data.get('items'):
return {
'outlet_id': outlet_id,
'outlet_name': inventory_data.get('tenant_name', f'Outlet {outlet_id}'),
'overall_coverage': 0,
'products': [],
'last_updated': datetime.now().isoformat()
}
# Process product details
products = []
total_coverage = 0
for item in inventory_data['items']:
# Filter by product_id if specified
if product_id and item.get('product_id') != product_id:
continue
current_stock = item.get('current_stock', 0)
safety_stock = item.get('safety_stock', 1)
if safety_stock <= 0:
safety_stock = 1
coverage = min(100, (current_stock / safety_stock) * 100)
total_coverage += coverage
# Determine risk level
if coverage < 30:
risk = 'critical'
elif coverage < 50:
risk = 'high'
elif coverage < 70:
risk = 'medium'
else:
risk = 'low'
# Filter by risk level if specified
if risk_level and risk != risk_level:
continue
# Calculate days until stockout (simplified)
daily_usage = item.get('average_daily_usage', 1)
days_until_stockout = None
if daily_usage > 0:
days_until_stockout = max(0, int((current_stock - safety_stock) / daily_usage))
if days_until_stockout < 0:
days_until_stockout = 0
product_detail = {
'product_id': item.get('product_id'),
'product_name': item.get('product_name', 'Unknown Product'),
'current_stock': current_stock,
'safety_stock': safety_stock,
'coverage_percentage': round(coverage, 1),
'risk_level': risk,
'days_until_stockout': days_until_stockout
}
products.append(product_detail)
# Calculate overall coverage
avg_coverage = total_coverage / len(inventory_data['items']) if inventory_data['items'] else 0
return {
'outlet_id': outlet_id,
'outlet_name': inventory_data.get('tenant_name', f'Outlet {outlet_id}'),
'overall_coverage': round(avg_coverage, 1),
'products': products,
'last_updated': datetime.now().isoformat()
}
except Exception as e:
logger.error("Failed to get outlet inventory details", outlet_id=outlet_id, error=str(e))
raise Exception(f"Failed to get outlet inventory details: {str(e)}")
async def get_inventory_alerts(self, parent_id: str) -> List[Dict[str, Any]]:
"""
Get real-time inventory alerts across all outlets
"""
try:
# Get all child outlets
child_outlets = await self.get_child_outlets(parent_id)
alerts = []
for outlet in child_outlets:
outlet_id = outlet['id']
outlet_name = outlet['name']
# Get inventory coverage for this outlet
coverage = await self.get_inventory_coverage(outlet_id)
if coverage:
# Create alerts for critical items
if coverage['critical_items_count'] > 0:
alerts.append({
'alert_id': str(uuid.uuid4()),
'outlet_id': outlet_id,
'outlet_name': outlet_name,
'product_id': None,
'product_name': None,
'alert_type': 'low_coverage',
'severity': 'critical',
'current_coverage': coverage['overall_coverage'],
'threshold': 30,
'timestamp': datetime.now().isoformat(),
'message': f"Critical inventory coverage: {coverage['overall_coverage']}% (threshold: 30%)"
})
# Create alerts for high risk items
if coverage['high_risk_items_count'] > 0:
alerts.append({
'alert_id': str(uuid.uuid4()),
'outlet_id': outlet_id,
'outlet_name': outlet_name,
'product_id': None,
'product_name': None,
'alert_type': 'stockout_risk',
'severity': 'high',
'current_coverage': coverage['overall_coverage'],
'threshold': 50,
'timestamp': datetime.now().isoformat(),
'message': f"High stockout risk: {coverage['overall_coverage']}% coverage"
})
return alerts
except Exception as e:
logger.error("Failed to get inventory alerts", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get inventory alerts: {str(e)}")
async def get_transfer_recommendations(self, parent_id: str, urgency: str = "medium") -> List[Dict[str, Any]]:
"""
Get AI-powered inventory transfer recommendations
"""
try:
# Get inventory coverage for all outlets
child_outlets = await self.get_child_outlets(parent_id)
coverage_data = []
for outlet in child_outlets:
coverage = await self.get_inventory_coverage(outlet['id'])
if coverage:
coverage_data.append(coverage)
# Simple recommendation algorithm (in real implementation, this would be more sophisticated)
recommendations = []
# Find outlets with surplus and deficit
surplus_outlets = [c for c in coverage_data if c['overall_coverage'] > 85]
deficit_outlets = [c for c in coverage_data if c['overall_coverage'] < 60]
# Generate transfer recommendations
for deficit in deficit_outlets:
for surplus in surplus_outlets:
# Calculate transfer amount (simplified)
transfer_amount = min(10, (deficit['overall_coverage'] - 60) * -2) # Transfer 2% per missing %
if transfer_amount > 0:
recommendations.append({
'recommendation_id': str(uuid.uuid4()),
'from_outlet_id': surplus['outlet_id'],
'from_outlet_name': surplus['outlet_name'],
'to_outlet_id': deficit['outlet_id'],
'to_outlet_name': deficit['outlet_name'],
'transfer_amount': transfer_amount,
'priority': self._calculate_priority(deficit, urgency),
'reason': f"Balance inventory: {surplus['outlet_name']} has {surplus['overall_coverage']}% coverage, {deficit['outlet_name']} has {deficit['overall_coverage']}% coverage",
'estimated_impact': f"Improve {deficit['outlet_name']} coverage by ~{transfer_amount}%"
})
# Sort by priority
recommendations.sort(key=lambda x: x['priority'], reverse=True)
return recommendations
except Exception as e:
logger.error("Failed to get transfer recommendations", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get transfer recommendations: {str(e)}")
def _calculate_priority(self, deficit_coverage: Dict[str, Any], urgency: str) -> int:
"""
Calculate priority score for transfer recommendation
"""
priority_scores = {
'critical': 4,
'high': 3,
'medium': 2,
'low': 1
}
urgency_score = priority_scores.get(urgency, 2)
# Higher priority for lower coverage
coverage_score = max(1, 5 - int(deficit_coverage['overall_coverage'] / 20))
return urgency_score * coverage_score
async def get_coverage_trends(self, parent_id: str, days: int = 30) -> List[Dict[str, Any]]:
"""
Get historical inventory coverage trends
"""
try:
# In a real implementation, this would query historical data
# For demo purposes, generate some sample trend data
trends = []
end_date = datetime.now()
for i in range(days):
date = end_date - timedelta(days=i)
# Generate sample data with some variation
base_coverage = 75
variation = (i % 7) - 3 # Weekly pattern
daily_variation = (i % 3) - 1 # Daily noise
coverage = max(50, min(95, base_coverage + variation + daily_variation))
trends.append({
'date': date.strftime('%Y-%m-%d'),
'average_coverage': round(coverage, 1),
'min_coverage': max(40, coverage - 15),
'max_coverage': min(95, coverage + 10)
})
# Sort by date (oldest first)
trends.sort(key=lambda x: x['date'])
return trends
except Exception as e:
logger.error("Failed to get coverage trends", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get coverage trends: {str(e)}")
async def verify_parent_child_relationship(self, parent_id: str, child_id: str) -> bool:
"""
Verify that a child tenant belongs to a parent tenant
"""
try:
# Get child tenant info
child_info = await self.tenant_client.get_tenant(child_id)
if child_info.get('parent_tenant_id') != parent_id:
raise HTTPException(
status_code=403,
detail="Child tenant does not belong to specified parent"
)
return True
except Exception as e:
logger.error("Failed to verify parent-child relationship", parent_id=parent_id, child_id=child_id, error=str(e))
raise Exception(f"Failed to verify relationship: {str(e)}")

View File

@@ -0,0 +1,445 @@
"""
Network Alerts API
Endpoints for aggregating and managing alerts across enterprise networks
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Dict, Any, Optional
from datetime import datetime
from pydantic import BaseModel, Field
import structlog
from app.services.network_alerts_service import NetworkAlertsService
from shared.auth.tenant_access import verify_tenant_permission_dep
from shared.clients import get_tenant_client, get_alerts_client
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter()
# Pydantic models for request/response
class NetworkAlert(BaseModel):
alert_id: str = Field(..., description="Unique alert ID")
tenant_id: str = Field(..., description="Tenant ID where alert originated")
tenant_name: str = Field(..., description="Tenant name")
alert_type: str = Field(..., description="Type of alert: inventory, production, delivery, etc.")
severity: str = Field(..., description="Severity: critical, high, medium, low")
title: str = Field(..., description="Alert title")
message: str = Field(..., description="Alert message")
timestamp: str = Field(..., description="Alert timestamp")
status: str = Field(..., description="Alert status: active, acknowledged, resolved")
source_system: str = Field(..., description="System that generated the alert")
related_entity_id: Optional[str] = Field(None, description="ID of related entity (product, route, etc.)")
related_entity_type: Optional[str] = Field(None, description="Type of related entity")
class AlertSeveritySummary(BaseModel):
critical_count: int = Field(..., description="Number of critical alerts")
high_count: int = Field(..., description="Number of high severity alerts")
medium_count: int = Field(..., description="Number of medium severity alerts")
low_count: int = Field(..., description="Number of low severity alerts")
total_alerts: int = Field(..., description="Total number of alerts")
class AlertTypeSummary(BaseModel):
inventory_alerts: int = Field(..., description="Inventory-related alerts")
production_alerts: int = Field(..., description="Production-related alerts")
delivery_alerts: int = Field(..., description="Delivery-related alerts")
equipment_alerts: int = Field(..., description="Equipment-related alerts")
quality_alerts: int = Field(..., description="Quality-related alerts")
other_alerts: int = Field(..., description="Other types of alerts")
class NetworkAlertsSummary(BaseModel):
total_alerts: int = Field(..., description="Total alerts across network")
active_alerts: int = Field(..., description="Currently active alerts")
acknowledged_alerts: int = Field(..., description="Acknowledged alerts")
resolved_alerts: int = Field(..., description="Resolved alerts")
severity_summary: AlertSeveritySummary = Field(..., description="Alerts by severity")
type_summary: AlertTypeSummary = Field(..., description="Alerts by type")
most_recent_alert: Optional[NetworkAlert] = Field(None, description="Most recent alert")
class AlertCorrelation(BaseModel):
correlation_id: str = Field(..., description="Correlation group ID")
primary_alert: NetworkAlert = Field(..., description="Primary alert in the group")
related_alerts: List[NetworkAlert] = Field(..., description="Alerts correlated with primary alert")
correlation_type: str = Field(..., description="Type of correlation: causal, temporal, spatial")
correlation_strength: float = Field(..., description="Correlation strength (0-1)")
impact_analysis: str = Field(..., description="Analysis of combined impact")
async def get_network_alerts_service() -> NetworkAlertsService:
"""Dependency injection for NetworkAlertsService"""
tenant_client = get_tenant_client(settings, "tenant-service")
alerts_client = get_alerts_client(settings, "tenant-service")
return NetworkAlertsService(tenant_client, alerts_client)
@router.get("/tenants/{parent_id}/network/alerts",
response_model=List[NetworkAlert],
summary="Get aggregated alerts across network")
async def get_network_alerts(
parent_id: str,
severity: Optional[str] = Query(None, description="Filter by severity: critical, high, medium, low"),
alert_type: Optional[str] = Query(None, description="Filter by alert type"),
status: Optional[str] = Query(None, description="Filter by status: active, acknowledged, resolved"),
limit: int = Query(100, description="Maximum number of alerts to return"),
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get aggregated alerts across all child tenants in a parent network
This endpoint provides a unified view of alerts across the entire enterprise network,
enabling network managers to identify and prioritize issues that require attention.
"""
try:
# Verify this is a parent tenant
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access network alerts"
)
# Get all child tenants
child_tenants = await network_alerts_service.get_child_tenants(parent_id)
if not child_tenants:
return []
# Aggregate alerts from all child tenants
all_alerts = []
for child in child_tenants:
child_id = child['id']
child_name = child['name']
# Get alerts for this child tenant
child_alerts = await network_alerts_service.get_alerts_for_tenant(child_id)
# Enrich with tenant information and apply filters
for alert in child_alerts:
enriched_alert = {
'alert_id': alert.get('alert_id', str(uuid.uuid4())),
'tenant_id': child_id,
'tenant_name': child_name,
'alert_type': alert.get('alert_type', 'unknown'),
'severity': alert.get('severity', 'medium'),
'title': alert.get('title', 'No title'),
'message': alert.get('message', 'No message'),
'timestamp': alert.get('timestamp', datetime.now().isoformat()),
'status': alert.get('status', 'active'),
'source_system': alert.get('source_system', 'unknown'),
'related_entity_id': alert.get('related_entity_id'),
'related_entity_type': alert.get('related_entity_type')
}
# Apply filters
if severity and enriched_alert['severity'] != severity:
continue
if alert_type and enriched_alert['alert_type'] != alert_type:
continue
if status and enriched_alert['status'] != status:
continue
all_alerts.append(enriched_alert)
# Sort by severity (critical first) and timestamp (newest first)
severity_order = {'critical': 1, 'high': 2, 'medium': 3, 'low': 4}
all_alerts.sort(key=lambda x: (severity_order.get(x['severity'], 5), -int(x['timestamp'] or 0)))
return all_alerts[:limit]
except Exception as e:
logger.error("Failed to get network alerts", parent_id=parent_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get network alerts: {str(e)}")
@router.get("/tenants/{parent_id}/network/alerts/summary",
response_model=NetworkAlertsSummary,
summary="Get network alerts summary")
async def get_network_alerts_summary(
parent_id: str,
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get summary of alerts across the network
Provides aggregated metrics and statistics about alerts across all child tenants.
"""
try:
# Verify this is a parent tenant
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access network alerts summary"
)
# Get all network alerts
all_alerts = await network_alerts_service.get_network_alerts(parent_id)
if not all_alerts:
return NetworkAlertsSummary(
total_alerts=0,
active_alerts=0,
acknowledged_alerts=0,
resolved_alerts=0,
severity_summary=AlertSeveritySummary(
critical_count=0,
high_count=0,
medium_count=0,
low_count=0,
total_alerts=0
),
type_summary=AlertTypeSummary(
inventory_alerts=0,
production_alerts=0,
delivery_alerts=0,
equipment_alerts=0,
quality_alerts=0,
other_alerts=0
),
most_recent_alert=None
)
# Calculate summary metrics
active_alerts = sum(1 for a in all_alerts if a['status'] == 'active')
acknowledged_alerts = sum(1 for a in all_alerts if a['status'] == 'acknowledged')
resolved_alerts = sum(1 for a in all_alerts if a['status'] == 'resolved')
# Calculate severity summary
severity_summary = AlertSeveritySummary(
critical_count=sum(1 for a in all_alerts if a['severity'] == 'critical'),
high_count=sum(1 for a in all_alerts if a['severity'] == 'high'),
medium_count=sum(1 for a in all_alerts if a['severity'] == 'medium'),
low_count=sum(1 for a in all_alerts if a['severity'] == 'low'),
total_alerts=len(all_alerts)
)
# Calculate type summary
type_summary = AlertTypeSummary(
inventory_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'inventory'),
production_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'production'),
delivery_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'delivery'),
equipment_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'equipment'),
quality_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'quality'),
other_alerts=sum(1 for a in all_alerts if a['alert_type'] not in ['inventory', 'production', 'delivery', 'equipment', 'quality'])
)
# Get most recent alert
most_recent_alert = None
if all_alerts:
most_recent_alert = max(all_alerts, key=lambda x: x['timestamp'])
return NetworkAlertsSummary(
total_alerts=len(all_alerts),
active_alerts=active_alerts,
acknowledged_alerts=acknowledged_alerts,
resolved_alerts=resolved_alerts,
severity_summary=severity_summary,
type_summary=type_summary,
most_recent_alert=most_recent_alert
)
except Exception as e:
logger.error("Failed to get network alerts summary", parent_id=parent_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get alerts summary: {str(e)}")
@router.get("/tenants/{parent_id}/network/alerts/correlations",
response_model=List[AlertCorrelation],
summary="Get correlated alert groups")
async def get_correlated_alerts(
parent_id: str,
min_correlation_strength: float = Query(0.7, ge=0.5, le=1.0, description="Minimum correlation strength"),
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get groups of correlated alerts
Identifies alerts that are related or have cascading effects across the network.
"""
try:
# Verify this is a parent tenant
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access alert correlations"
)
# Get all network alerts
all_alerts = await network_alerts_service.get_network_alerts(parent_id)
if not all_alerts:
return []
# Detect correlations (simplified for demo)
correlations = await network_alerts_service.detect_alert_correlations(
all_alerts, min_correlation_strength
)
return correlations
except Exception as e:
logger.error("Failed to get correlated alerts", parent_id=parent_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get alert correlations: {str(e)}")
@router.post("/tenants/{parent_id}/network/alerts/{alert_id}/acknowledge",
summary="Acknowledge network alert")
async def acknowledge_network_alert(
parent_id: str,
alert_id: str,
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Acknowledge a network alert
Marks an alert as acknowledged to indicate it's being addressed.
"""
try:
# Verify this is a parent tenant
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can acknowledge network alerts"
)
# Acknowledge the alert
result = await network_alerts_service.acknowledge_alert(parent_id, alert_id)
return {
'success': True,
'alert_id': alert_id,
'status': 'acknowledged',
'message': 'Alert acknowledged successfully'
}
except Exception as e:
logger.error("Failed to acknowledge alert", parent_id=parent_id, alert_id=alert_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to acknowledge alert: {str(e)}")
@router.post("/tenants/{parent_id}/network/alerts/{alert_id}/resolve",
summary="Resolve network alert")
async def resolve_network_alert(
parent_id: str,
alert_id: str,
resolution_notes: Optional[str] = Query(None, description="Notes about resolution"),
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Resolve a network alert
Marks an alert as resolved after the issue has been addressed.
"""
try:
# Verify this is a parent tenant
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can resolve network alerts"
)
# Resolve the alert
result = await network_alerts_service.resolve_alert(parent_id, alert_id, resolution_notes)
return {
'success': True,
'alert_id': alert_id,
'status': 'resolved',
'resolution_notes': resolution_notes,
'message': 'Alert resolved successfully'
}
except Exception as e:
logger.error("Failed to resolve alert", parent_id=parent_id, alert_id=alert_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to resolve alert: {str(e)}")
@router.get("/tenants/{parent_id}/network/alerts/trends",
summary="Get alert trends over time")
async def get_alert_trends(
parent_id: str,
days: int = Query(30, ge=7, le=365, description="Number of days to analyze"),
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get alert trends over time
Analyzes how alert patterns change over time to identify systemic issues.
"""
try:
# Verify this is a parent tenant
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access alert trends"
)
# Get alert trends
trends = await network_alerts_service.get_alert_trends(parent_id, days)
return {
'success': True,
'trends': trends,
'period': f'Last {days} days'
}
except Exception as e:
logger.error("Failed to get alert trends", parent_id=parent_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get alert trends: {str(e)}")
@router.get("/tenants/{parent_id}/network/alerts/prioritization",
summary="Get prioritized alerts")
async def get_prioritized_alerts(
parent_id: str,
limit: int = Query(10, description="Maximum number of alerts to return"),
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get prioritized alerts based on impact and urgency
Uses AI to prioritize alerts based on potential business impact and urgency.
"""
try:
# Verify this is a parent tenant
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access prioritized alerts"
)
# Get prioritized alerts
prioritized_alerts = await network_alerts_service.get_prioritized_alerts(parent_id, limit)
return {
'success': True,
'prioritized_alerts': prioritized_alerts,
'message': f'Top {len(prioritized_alerts)} prioritized alerts'
}
except Exception as e:
logger.error("Failed to get prioritized alerts", parent_id=parent_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get prioritized alerts: {str(e)}")
# Import datetime at runtime to avoid circular imports
from datetime import datetime, timedelta
import uuid

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from sqlalchemy import text from sqlalchemy import text
from app.core.config import settings from app.core.config import settings
from app.core.database import database_manager from app.core.database import database_manager
from app.api import tenants, tenant_members, tenant_operations, webhooks, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast, enterprise_upgrade, tenant_locations, tenant_hierarchy, internal_demo from app.api import tenants, tenant_members, tenant_operations, webhooks, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast, enterprise_upgrade, tenant_locations, tenant_hierarchy, internal_demo, network_alerts
from shared.service_base import StandardFastAPIService from shared.service_base import StandardFastAPIService
@@ -157,6 +157,7 @@ service.add_router(tenant_locations.router, tags=["tenant-locations"]) # Tenant
service.add_router(internal_demo.router, tags=["internal-demo"]) # Internal demo data cloning service.add_router(internal_demo.router, tags=["internal-demo"]) # Internal demo data cloning
service.add_router(tenant_hierarchy.router, tags=["tenant-hierarchy"]) # Tenant hierarchy endpoints service.add_router(tenant_hierarchy.router, tags=["tenant-hierarchy"]) # Tenant hierarchy endpoints
service.add_router(internal_demo.router, tags=["internal-demo"]) # Internal demo data cloning service.add_router(internal_demo.router, tags=["internal-demo"]) # Internal demo data cloning
service.add_router(network_alerts.router, tags=["network-alerts"]) # Network alerts aggregation endpoints
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -0,0 +1,365 @@
# services/tenant/app/services/network_alerts_service.py
"""
Network Alerts Service
Business logic for aggregating and managing alerts across enterprise networks
"""
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
import uuid
import structlog
logger = structlog.get_logger()
class NetworkAlertsService:
"""
Service for aggregating and managing alerts across enterprise networks
"""
def __init__(self, tenant_client, alerts_client):
self.tenant_client = tenant_client
self.alerts_client = alerts_client
async def get_child_tenants(self, parent_id: str) -> List[Dict[str, Any]]:
"""
Get all child tenants for a parent tenant
"""
try:
# Get child tenants from tenant service
children = await self.tenant_client.get_child_tenants(parent_id)
# Enrich with tenant details
enriched_children = []
for child in children:
child_details = await self.tenant_client.get_tenant(child['id'])
enriched_children.append({
'id': child['id'],
'name': child_details.get('name', f"Outlet {child['id']}"),
'subdomain': child_details.get('subdomain'),
'city': child_details.get('city')
})
return enriched_children
except Exception as e:
logger.error("Failed to get child tenants", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get child tenants: {str(e)}")
async def get_alerts_for_tenant(self, tenant_id: str) -> List[Dict[str, Any]]:
"""
Get alerts for a specific tenant
"""
try:
# In a real implementation, this would call the alert service
# For demo purposes, we'll simulate some alert data
# Simulate different types of alerts based on tenant type
simulated_alerts = []
# Generate some sample alerts
alert_types = ['inventory', 'production', 'delivery', 'equipment', 'quality']
severities = ['critical', 'high', 'medium', 'low']
for i in range(3): # Generate 3 sample alerts per tenant
alert = {
'alert_id': str(uuid.uuid4()),
'tenant_id': tenant_id,
'alert_type': alert_types[i % len(alert_types)],
'severity': severities[i % len(severities)],
'title': f"{alert_types[i % len(alert_types)].title()} Alert Detected",
'message': f"Sample {alert_types[i % len(alert_types)]} alert for tenant {tenant_id}",
'timestamp': (datetime.now() - timedelta(hours=i)).isoformat(),
'status': 'active' if i < 2 else 'resolved',
'source_system': f"{alert_types[i % len(alert_types)]}-service",
'related_entity_id': f"entity-{i+1}",
'related_entity_type': alert_types[i % len(alert_types)]
}
simulated_alerts.append(alert)
return simulated_alerts
except Exception as e:
logger.error("Failed to get alerts for tenant", tenant_id=tenant_id, error=str(e))
raise Exception(f"Failed to get alerts: {str(e)}")
async def get_network_alerts(self, parent_id: str) -> List[Dict[str, Any]]:
"""
Get all alerts across the network
"""
try:
# Get all child tenants
child_tenants = await self.get_child_tenants(parent_id)
# Aggregate alerts from all child tenants
all_alerts = []
for child in child_tenants:
child_id = child['id']
child_alerts = await self.get_alerts_for_tenant(child_id)
all_alerts.extend(child_alerts)
return all_alerts
except Exception as e:
logger.error("Failed to get network alerts", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get network alerts: {str(e)}")
async def detect_alert_correlations(
self,
alerts: List[Dict[str, Any]],
min_correlation_strength: float = 0.7
) -> List[Dict[str, Any]]:
"""
Detect correlations between alerts
"""
try:
# Simple correlation detection (in real implementation, this would be more sophisticated)
correlations = []
# Group alerts by type and time proximity
alert_groups = {}
for alert in alerts:
alert_type = alert['alert_type']
timestamp = alert['timestamp']
# Use timestamp as key for grouping (simplified)
if alert_type not in alert_groups:
alert_groups[alert_type] = []
alert_groups[alert_type].append(alert)
# Create correlation groups
for alert_type, group in alert_groups.items():
if len(group) >= 2: # Only create correlations for groups with 2+ alerts
primary_alert = group[0]
related_alerts = group[1:]
correlation = {
'correlation_id': str(uuid.uuid4()),
'primary_alert': primary_alert,
'related_alerts': related_alerts,
'correlation_type': 'temporal',
'correlation_strength': 0.85,
'impact_analysis': f"Multiple {alert_type} alerts detected within short timeframe"
}
if correlation['correlation_strength'] >= min_correlation_strength:
correlations.append(correlation)
return correlations
except Exception as e:
logger.error("Failed to detect alert correlations", error=str(e))
raise Exception(f"Failed to detect correlations: {str(e)}")
async def acknowledge_alert(self, parent_id: str, alert_id: str) -> Dict[str, Any]:
"""
Acknowledge an alert
"""
try:
# In a real implementation, this would update the alert status
# For demo purposes, we'll simulate the operation
logger.info("Alert acknowledged", parent_id=parent_id, alert_id=alert_id)
return {
'success': True,
'alert_id': alert_id,
'status': 'acknowledged'
}
except Exception as e:
logger.error("Failed to acknowledge alert", parent_id=parent_id, alert_id=alert_id, error=str(e))
raise Exception(f"Failed to acknowledge alert: {str(e)}")
async def resolve_alert(self, parent_id: str, alert_id: str, resolution_notes: Optional[str] = None) -> Dict[str, Any]:
"""
Resolve an alert
"""
try:
# In a real implementation, this would update the alert status
# For demo purposes, we'll simulate the operation
logger.info("Alert resolved", parent_id=parent_id, alert_id=alert_id, notes=resolution_notes)
return {
'success': True,
'alert_id': alert_id,
'status': 'resolved',
'resolution_notes': resolution_notes
}
except Exception as e:
logger.error("Failed to resolve alert", parent_id=parent_id, alert_id=alert_id, error=str(e))
raise Exception(f"Failed to resolve alert: {str(e)}")
async def get_alert_trends(self, parent_id: str, days: int = 30) -> List[Dict[str, Any]]:
"""
Get alert trends over time
"""
try:
# Simulate trend data
trends = []
end_date = datetime.now()
# Generate daily trend data
for i in range(days):
date = end_date - timedelta(days=i)
# Simulate varying alert counts with weekly pattern
base_count = 5
weekly_variation = int((i % 7) * 1.5) # Higher on weekdays
daily_noise = (i % 3 - 1) # Daily noise
alert_count = max(1, base_count + weekly_variation + daily_noise)
trends.append({
'date': date.strftime('%Y-%m-%d'),
'total_alerts': alert_count,
'critical_alerts': max(0, int(alert_count * 0.1)),
'high_alerts': max(0, int(alert_count * 0.2)),
'medium_alerts': max(0, int(alert_count * 0.4)),
'low_alerts': max(0, int(alert_count * 0.3))
})
# Sort by date (oldest first)
trends.sort(key=lambda x: x['date'])
return trends
except Exception as e:
logger.error("Failed to get alert trends", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get alert trends: {str(e)}")
async def get_prioritized_alerts(self, parent_id: str, limit: int = 10) -> List[Dict[str, Any]]:
"""
Get prioritized alerts based on impact and urgency
"""
try:
# Get all network alerts
all_alerts = await self.get_network_alerts(parent_id)
if not all_alerts:
return []
# Simple prioritization (in real implementation, this would use ML)
# Priority based on severity and recency
severity_scores = {'critical': 4, 'high': 3, 'medium': 2, 'low': 1}
for alert in all_alerts:
severity_score = severity_scores.get(alert['severity'], 1)
# Add recency score (newer alerts get higher priority)
timestamp = datetime.fromisoformat(alert['timestamp'])
recency_score = min(3, (datetime.now() - timestamp).days + 1)
alert['priority_score'] = severity_score * recency_score
# Sort by priority score (highest first)
all_alerts.sort(key=lambda x: x['priority_score'], reverse=True)
# Return top N alerts
prioritized = all_alerts[:limit]
# Remove priority score from response
for alert in prioritized:
alert.pop('priority_score', None)
return prioritized
except Exception as e:
logger.error("Failed to get prioritized alerts", parent_id=parent_id, error=str(e))
raise Exception(f"Failed to get prioritized alerts: {str(e)}")
# Helper class for alert analysis
class AlertAnalyzer:
"""
Helper class for analyzing alert patterns
"""
@staticmethod
def calculate_alert_severity_score(alert: Dict[str, Any]) -> float:
"""
Calculate severity score for an alert
"""
severity_scores = {'critical': 1.0, 'high': 0.75, 'medium': 0.5, 'low': 0.25}
return severity_scores.get(alert['severity'], 0.25)
@staticmethod
def detect_alert_patterns(alerts: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Detect patterns in alert data
"""
if not alerts:
return {'patterns': [], 'anomalies': []}
patterns = []
anomalies = []
# Simple pattern detection
alert_types = [a['alert_type'] for a in alerts]
type_counts = {}
for alert_type in alert_types:
type_counts[alert_type] = type_counts.get(alert_type, 0) + 1
# Detect if one type dominates
total_alerts = len(alerts)
for alert_type, count in type_counts.items():
if count / total_alerts > 0.6: # More than 60% of one type
patterns.append({
'type': 'dominant_alert_type',
'pattern': f'{alert_type} alerts dominate ({count}/{total_alerts})',
'confidence': 0.85
})
return {'patterns': patterns, 'anomalies': anomalies}
# Helper class for alert correlation
class AlertCorrelator:
"""
Helper class for correlating alerts
"""
@staticmethod
def calculate_correlation_strength(alert1: Dict[str, Any], alert2: Dict[str, Any]) -> float:
"""
Calculate correlation strength between two alerts
"""
# Simple correlation based on type and time proximity
same_type = 1.0 if alert1['alert_type'] == alert2['alert_type'] else 0.3
time1 = datetime.fromisoformat(alert1['timestamp'])
time2 = datetime.fromisoformat(alert2['timestamp'])
time_diff_hours = abs((time2 - time1).total_seconds() / 3600)
# Time proximity score (higher for closer times)
time_proximity = max(0, 1.0 - min(1.0, time_diff_hours / 24)) # Decays over 24 hours
return same_type * time_proximity
# Helper class for alert prioritization
class AlertPrioritizer:
"""
Helper class for prioritizing alerts
"""
@staticmethod
def calculate_priority_score(alert: Dict[str, Any]) -> float:
"""
Calculate priority score for an alert
"""
# Base score from severity
severity_scores = {'critical': 100, 'high': 75, 'medium': 50, 'low': 25}
base_score = severity_scores.get(alert['severity'], 25)
# Add recency bonus (newer alerts get higher priority)
timestamp = datetime.fromisoformat(alert['timestamp'])
hours_old = (datetime.now() - timestamp).total_seconds() / 3600
recency_bonus = max(0, 50 - hours_old) # Decays over 50 hours
return base_score + recency_bonus