Imporve enterprise
This commit is contained in:
195
DEPLOYMENT_TROUBLESHOOTING.md
Normal file
195
DEPLOYMENT_TROUBLESHOOTING.md
Normal 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
|
||||||
582
frontend/src/components/dashboard/DistributionTab.tsx
Normal file
582
frontend/src/components/dashboard/DistributionTab.tsx
Normal 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;
|
||||||
340
frontend/src/components/dashboard/NetworkOverviewTab.tsx
Normal file
340
frontend/src/components/dashboard/NetworkOverviewTab.tsx
Normal 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;
|
||||||
531
frontend/src/components/dashboard/NetworkPerformanceTab.tsx
Normal file
531
frontend/src/components/dashboard/NetworkPerformanceTab.tsx
Normal 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;
|
||||||
603
frontend/src/components/dashboard/OutletFulfillmentTab.tsx
Normal file
603
frontend/src/components/dashboard/OutletFulfillmentTab.tsx
Normal 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;
|
||||||
410
frontend/src/components/dashboard/ProductionTab.tsx
Normal file
410
frontend/src/components/dashboard/ProductionTab.tsx
Normal 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;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
48
scripts/fix_inotify_limits.sh
Executable 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
100
scripts/fix_kubernetes_inotify.sh
Executable 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!"
|
||||||
341
services/distribution/app/api/vrp_optimization.py
Normal file
341
services/distribution/app/api/vrp_optimization.py
Normal 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
|
||||||
@@ -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"])
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
357
services/distribution/app/services/vrp_optimization_service.py
Normal file
357
services/distribution/app/services/vrp_optimization_service.py
Normal 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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
417
services/forecasting/app/api/forecast_feedback.py
Normal file
417
services/forecasting/app/api/forecast_feedback.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
533
services/forecasting/app/services/forecast_feedback_service.py
Normal file
533
services/forecasting/app/services/forecast_feedback_service.py
Normal 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)
|
||||||
314
services/inventory/app/api/enterprise_inventory.py
Normal file
314
services/inventory/app/api/enterprise_inventory.py
Normal 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)}")
|
||||||
@@ -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__":
|
||||||
|
|||||||
473
services/inventory/app/services/enterprise_inventory_service.py
Normal file
473
services/inventory/app/services/enterprise_inventory_service.py
Normal 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)}")
|
||||||
445
services/tenant/app/api/network_alerts.py
Normal file
445
services/tenant/app/api/network_alerts.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
365
services/tenant/app/services/network_alerts_service.py
Normal file
365
services/tenant/app/services/network_alerts_service.py
Normal 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
|
||||||
Reference in New Issue
Block a user