Delete legacy alerts

This commit is contained in:
Urtzi Alfaro
2025-08-22 15:31:52 +02:00
parent c6dd6fd1de
commit 90100a66c6
40 changed files with 25 additions and 3308 deletions

View File

@@ -12,7 +12,6 @@ import {
InventoryItem,
StockLevel,
StockMovement,
StockAlert,
InventorySearchParams,
CreateInventoryItemRequest,
UpdateInventoryItemRequest,
@@ -31,7 +30,6 @@ interface UseInventoryReturn {
items: InventoryItem[];
stockLevels: Record<string, StockLevel>;
movements: StockMovement[];
alerts: StockAlert[];
dashboardData: InventoryDashboardData | null;
isLoading: boolean;
error: string | null;
@@ -54,9 +52,6 @@ interface UseInventoryReturn {
adjustStock: (itemId: string, adjustment: StockAdjustmentRequest) => Promise<StockMovement | null>;
loadMovements: (params?: any) => Promise<void>;
// Alerts
loadAlerts: () => Promise<void>;
acknowledgeAlert: (alertId: string) => Promise<boolean>;
// Dashboard
loadDashboard: () => Promise<void>;
@@ -69,7 +64,6 @@ interface UseInventoryReturn {
interface UseInventoryDashboardReturn {
dashboardData: InventoryDashboardData | null;
alerts: StockAlert[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
@@ -95,7 +89,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
const [items, setItems] = useState<InventoryItem[]>([]);
const [stockLevels, setStockLevels] = useState<Record<string, StockLevel>>({});
const [movements, setMovements] = useState<StockMovement[]>([]);
const [alerts, setAlerts] = useState<StockAlert[]>([]);
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -278,34 +271,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
}
}, [tenantId]);
// Load alerts
const loadAlerts = useCallback(async () => {
if (!tenantId) return;
try {
const alertsData = await inventoryService.getStockAlerts(tenantId);
setAlerts(alertsData);
} catch (err: any) {
console.error('Error loading alerts:', err);
// Don't show toast error for this as it's not critical for forecast page
}
}, [tenantId]);
// Acknowledge alert
const acknowledgeAlert = useCallback(async (alertId: string): Promise<boolean> => {
if (!tenantId) return false;
try {
await inventoryService.acknowledgeAlert(tenantId, alertId);
setAlerts(prev => prev.map(a =>
a.id === alertId ? { ...a, is_acknowledged: true, acknowledged_at: new Date().toISOString() } : a
));
return true;
} catch (err: any) {
toast.error('Error acknowledging alert');
return false;
}
}, [tenantId]);
// Load dashboard
const loadDashboard = useCallback(async () => {
@@ -337,10 +302,9 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
await Promise.all([
loadItems(),
loadStockLevels(),
loadAlerts(),
loadDashboard()
]);
}, [loadItems, loadStockLevels, loadAlerts, loadDashboard]);
}, [loadItems, loadStockLevels, loadDashboard]);
// Auto-load on mount
useEffect(() => {
@@ -354,7 +318,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
items,
stockLevels,
movements,
alerts,
dashboardData,
isLoading,
error,
@@ -372,10 +335,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
adjustStock,
loadMovements,
// Alerts
loadAlerts,
acknowledgeAlert,
// Dashboard
loadDashboard,
@@ -391,7 +350,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
const { tenantId } = useTenantId();
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
const [alerts, setAlerts] = useState<StockAlert[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -402,13 +360,9 @@ export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
setError(null);
try {
const [dashboard, alertsData] = await Promise.all([
inventoryService.getDashboardData(tenantId),
inventoryService.getStockAlerts(tenantId)
]);
const dashboard = await inventoryService.getDashboardData(tenantId);
setDashboardData(dashboard);
setAlerts(alertsData);
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading dashboard';
setError(errorMessage);
@@ -425,7 +379,6 @@ export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
return {
dashboardData,
alerts,
isLoading,
error,
refresh

View File

@@ -105,22 +105,6 @@ export interface StockMovement {
batch_info?: StockBatch;
}
export interface StockAlert {
id: string;
item_id: string;
alert_type: 'low_stock' | 'expired' | 'expiring_soon' | 'overstock';
severity: 'low' | 'medium' | 'high' | 'critical';
message: string;
threshold_value?: number;
current_value?: number;
is_acknowledged: boolean;
created_at: string;
acknowledged_at?: string;
acknowledged_by?: string;
// Related data
item?: InventoryItem;
}
// ========== REQUEST/RESPONSE TYPES ==========
@@ -404,32 +388,6 @@ export class InventoryService {
return apiClient.get(url);
}
// ========== ALERTS ==========
/**
* Get current stock alerts
*/
async getStockAlerts(tenantId: string): Promise<StockAlert[]> {
// TODO: Map to correct endpoint when available
return [];
// return apiClient.get(`/tenants/${tenantId}/inventory/alerts`);
}
/**
* Acknowledge alert
*/
async acknowledgeAlert(tenantId: string, alertId: string): Promise<void> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts/${alertId}/acknowledge`);
}
/**
* Bulk acknowledge alerts
*/
async bulkAcknowledgeAlerts(tenantId: string, alertIds: string[]): Promise<void> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts/bulk-acknowledge`, {
alert_ids: alertIds
});
}
// ========== DASHBOARD & ANALYTICS ==========
@@ -771,23 +729,6 @@ export class InventoryService {
}
}
/**
* Get inventory alerts
*/
async getInventoryAlerts(tenantId: string, params?: {
alert_type?: string;
severity?: string;
status?: string;
item_id?: string;
limit?: number;
}): Promise<any[]> {
try {
return await apiClient.get(`/tenants/${tenantId}/inventory/alerts`, { params });
} catch (error) {
console.error('❌ Error fetching inventory alerts:', error);
throw error;
}
}
/**
* Get restock recommendations

View File

@@ -344,20 +344,6 @@ export class OrdersService {
return apiClient.get(`${this.basePath}/analytics/seasonal`, { params });
}
// Alerts
async getOrderAlerts(params?: {
severity?: string;
status?: string;
limit?: number;
}): Promise<any[]> {
return apiClient.get(`${this.basePath}/alerts`, { params });
}
async acknowledgeAlert(alertId: string): Promise<void> {
return apiClient.post(`${this.basePath}/alerts/${alertId}/acknowledge`);
}
async resolveAlert(alertId: string, resolution?: string): Promise<void> {
return apiClient.post(`${this.basePath}/alerts/${alertId}/resolve`, { resolution });
}
}

View File

@@ -66,13 +66,11 @@ export interface ProductionDashboardData {
equipment_in_use: number;
current_efficiency: number;
todays_production: number;
alerts_count: number;
};
efficiency_trend: { date: string; efficiency: number }[];
quality_trend: { date: string; quality: number }[];
equipment_status: Equipment[];
active_batches: ProductionBatch[];
alerts: any[];
}
export interface BatchCreateRequest {
@@ -174,8 +172,7 @@ export class ProductionService {
temperature: number;
humidity: number;
estimated_completion: string;
alerts: any[];
}> {
}> {
return apiClient.get(`${this.basePath}/batches/${batchId}/status`);
}
@@ -295,20 +292,5 @@ export class ProductionService {
return apiClient.get(`${this.basePath}/analytics/quality`, { params });
}
// Alerts
async getProductionAlerts(params?: {
severity?: string;
status?: string;
limit?: number;
}): Promise<any[]> {
return apiClient.get(`${this.basePath}/alerts`, { params });
}
async acknowledgeAlert(alertId: string): Promise<void> {
return apiClient.post(`${this.basePath}/alerts/${alertId}/acknowledge`);
}
async resolveAlert(alertId: string, resolution?: string): Promise<void> {
return apiClient.post(`${this.basePath}/alerts/${alertId}/resolve`, { resolution });
}
}

View File

@@ -188,7 +188,6 @@ export interface SupplierDashboardData {
quality_scores: number[];
cost_savings: number[];
};
alerts: any[];
contract_expirations: { supplier_name: string; days_until_expiry: number }[];
}
@@ -320,23 +319,7 @@ export class SuppliersService {
return apiClient.get(`${this.basePath}/analytics/risk-analysis`);
}
// Alerts
async getSupplierAlerts(params?: {
severity?: string;
status?: string;
supplier_id?: string;
limit?: number;
}): Promise<any[]> {
return apiClient.get(`${this.basePath}/alerts`, { params });
}
async acknowledgeAlert(alertId: string): Promise<void> {
return apiClient.post(`${this.basePath}/alerts/${alertId}/acknowledge`);
}
async resolveAlert(alertId: string, resolution?: string): Promise<void> {
return apiClient.post(`${this.basePath}/alerts/${alertId}/resolve`, { resolution });
}
// Additional methods for hooks compatibility
async getSupplierStatistics(): Promise<SupplierStatistics> {

View File

@@ -21,12 +21,12 @@ const InventoryDashboardWidget: React.FC<InventoryDashboardWidgetProps> = ({
onViewInventory,
className = ''
}) => {
const { dashboardData, alerts, isLoading, error, refresh } = useInventoryDashboard();
const { dashboardData, isLoading, error, refresh } = useInventoryDashboard();
// Get alert counts
const criticalAlerts = alerts.filter(a => !a.is_acknowledged && a.severity === 'critical').length;
const lowStockAlerts = alerts.filter(a => !a.is_acknowledged && a.alert_type === 'low_stock').length;
const expiringAlerts = alerts.filter(a => !a.is_acknowledged && a.alert_type === 'expiring_soon').length;
const criticalAlerts = 0;
const lowStockAlerts = 0;
const expiringAlerts = 0;
if (isLoading) {
return (

View File

@@ -1,359 +0,0 @@
import React, { useState } from 'react';
import {
AlertTriangle,
Clock,
Package,
TrendingDown,
CheckCircle,
X,
Filter,
Bell,
BellOff,
Calendar
} from 'lucide-react';
import { StockAlert } from '../../api/services/inventory.service';
interface StockAlertsPanelProps {
alerts: StockAlert[];
onAcknowledge?: (alertId: string) => void;
onAcknowledgeAll?: (alertIds: string[]) => void;
onViewItem?: (itemId: string) => void;
className?: string;
}
type AlertFilter = 'all' | 'unacknowledged' | 'low_stock' | 'expired' | 'expiring_soon';
const StockAlertsPanel: React.FC<StockAlertsPanelProps> = ({
alerts,
onAcknowledge,
onAcknowledgeAll,
onViewItem,
className = ''
}) => {
const [filter, setFilter] = useState<AlertFilter>('all');
const [selectedAlerts, setSelectedAlerts] = useState<Set<string>>(new Set());
// Filter alerts based on current filter
const filteredAlerts = alerts.filter(alert => {
switch (filter) {
case 'unacknowledged':
return !alert.is_acknowledged;
case 'low_stock':
return alert.alert_type === 'low_stock';
case 'expired':
return alert.alert_type === 'expired';
case 'expiring_soon':
return alert.alert_type === 'expiring_soon';
default:
return true;
}
});
// Get alert icon
const getAlertIcon = (alert: StockAlert) => {
switch (alert.alert_type) {
case 'low_stock':
return <TrendingDown className="w-5 h-5" />;
case 'expired':
return <X className="w-5 h-5" />;
case 'expiring_soon':
return <Clock className="w-5 h-5" />;
case 'overstock':
return <Package className="w-5 h-5" />;
default:
return <AlertTriangle className="w-5 h-5" />;
}
};
// Get alert color classes
const getAlertClasses = (alert: StockAlert) => {
const baseClasses = 'border-l-4';
if (alert.is_acknowledged) {
return `${baseClasses} border-gray-300 bg-gray-50`;
}
switch (alert.severity) {
case 'critical':
return `${baseClasses} border-red-500 bg-red-50`;
case 'high':
return `${baseClasses} border-orange-500 bg-orange-50`;
case 'medium':
return `${baseClasses} border-yellow-500 bg-yellow-50`;
case 'low':
return `${baseClasses} border-blue-500 bg-blue-50`;
default:
return `${baseClasses} border-gray-500 bg-gray-50`;
}
};
// Get alert text color
const getAlertTextColor = (alert: StockAlert) => {
if (alert.is_acknowledged) {
return 'text-gray-600';
}
switch (alert.severity) {
case 'critical':
return 'text-red-700';
case 'high':
return 'text-orange-700';
case 'medium':
return 'text-yellow-700';
case 'low':
return 'text-blue-700';
default:
return 'text-gray-700';
}
};
// Get alert icon color
const getAlertIconColor = (alert: StockAlert) => {
if (alert.is_acknowledged) {
return 'text-gray-400';
}
switch (alert.severity) {
case 'critical':
return 'text-red-500';
case 'high':
return 'text-orange-500';
case 'medium':
return 'text-yellow-500';
case 'low':
return 'text-blue-500';
default:
return 'text-gray-500';
}
};
// Handle alert selection
const toggleAlertSelection = (alertId: string) => {
const newSelection = new Set(selectedAlerts);
if (newSelection.has(alertId)) {
newSelection.delete(alertId);
} else {
newSelection.add(alertId);
}
setSelectedAlerts(newSelection);
};
// Handle acknowledge all selected
const handleAcknowledgeSelected = () => {
if (onAcknowledgeAll && selectedAlerts.size > 0) {
onAcknowledgeAll(Array.from(selectedAlerts));
setSelectedAlerts(new Set());
}
};
// Format time ago
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) {
return 'Hace menos de 1 hora';
} else if (diffInHours < 24) {
return `Hace ${diffInHours} horas`;
} else {
const diffInDays = Math.floor(diffInHours / 24);
return `Hace ${diffInDays} días`;
}
};
// Get filter counts
const getFilterCounts = () => {
return {
all: alerts.length,
unacknowledged: alerts.filter(a => !a.is_acknowledged).length,
low_stock: alerts.filter(a => a.alert_type === 'low_stock').length,
expired: alerts.filter(a => a.alert_type === 'expired').length,
expiring_soon: alerts.filter(a => a.alert_type === 'expiring_soon').length,
};
};
const filterCounts = getFilterCounts();
return (
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
{/* Header */}
<div className="p-6 border-b">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<Bell className="w-5 h-5 text-gray-600" />
<h2 className="text-lg font-semibold text-gray-900">Alertas de Stock</h2>
{filterCounts.unacknowledged > 0 && (
<span className="bg-red-100 text-red-800 text-xs px-2 py-1 rounded-full">
{filterCounts.unacknowledged} pendientes
</span>
)}
</div>
{selectedAlerts.size > 0 && (
<button
onClick={handleAcknowledgeSelected}
className="flex items-center space-x-1 px-3 py-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"
>
<CheckCircle className="w-4 h-4" />
<span>Confirmar ({selectedAlerts.size})</span>
</button>
)}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
{[
{ key: 'all', label: 'Todas', count: filterCounts.all },
{ key: 'unacknowledged', label: 'Pendientes', count: filterCounts.unacknowledged },
{ key: 'low_stock', label: 'Stock Bajo', count: filterCounts.low_stock },
{ key: 'expired', label: 'Vencidas', count: filterCounts.expired },
{ key: 'expiring_soon', label: 'Por Vencer', count: filterCounts.expiring_soon },
].map(({ key, label, count }) => (
<button
key={key}
onClick={() => setFilter(key as AlertFilter)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{label} ({count})
</button>
))}
</div>
</div>
{/* Alerts List */}
<div className="divide-y">
{filteredAlerts.length === 0 ? (
<div className="p-8 text-center">
<BellOff className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{filter === 'all' ? 'No hay alertas' : 'No hay alertas con este filtro'}
</h3>
<p className="text-gray-500">
{filter === 'all'
? 'Tu inventario está en buen estado'
: 'Prueba con un filtro diferente'
}
</p>
</div>
) : (
filteredAlerts.map((alert) => (
<div
key={alert.id}
className={`p-4 hover:bg-gray-50 transition-colors ${getAlertClasses(alert)}`}
>
<div className="flex items-start space-x-3">
{/* Selection checkbox */}
{!alert.is_acknowledged && (
<input
type="checkbox"
checked={selectedAlerts.has(alert.id)}
onChange={() => toggleAlertSelection(alert.id)}
className="mt-1 w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
)}
{/* Alert Icon */}
<div className={`mt-0.5 ${getAlertIconColor(alert)}`}>
{getAlertIcon(alert)}
</div>
{/* Alert Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h4 className={`font-medium ${getAlertTextColor(alert)}`}>
{alert.item?.name || 'Producto desconocido'}
</h4>
<p className={`text-sm mt-1 ${getAlertTextColor(alert)}`}>
{alert.message}
</p>
{/* Additional Info */}
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
<div className="flex items-center space-x-1">
<Calendar className="w-3 h-3" />
<span>{formatTimeAgo(alert.created_at)}</span>
</div>
{alert.threshold_value && alert.current_value && (
<span>
Umbral: {alert.threshold_value} | Actual: {alert.current_value}
</span>
)}
<span className="capitalize">
Severidad: {alert.severity}
</span>
</div>
{/* Acknowledged Info */}
{alert.is_acknowledged && alert.acknowledged_at && (
<div className="mt-2 text-xs text-gray-500">
<span> Confirmada {formatTimeAgo(alert.acknowledged_at)}</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 ml-4">
{onViewItem && alert.item_id && (
<button
onClick={() => onViewItem(alert.item_id)}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Ver producto
</button>
)}
{!alert.is_acknowledged && onAcknowledge && (
<button
onClick={() => onAcknowledge(alert.id)}
className="text-green-600 hover:text-green-800 text-sm font-medium"
>
Confirmar
</button>
)}
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* Footer with bulk actions */}
{filteredAlerts.length > 0 && filterCounts.unacknowledged > 0 && (
<div className="p-4 border-t bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
{filterCounts.unacknowledged} alertas pendientes
</span>
<button
onClick={() => {
if (onAcknowledgeAll) {
const unacknowledgedIds = alerts
.filter(a => !a.is_acknowledged)
.map(a => a.id);
onAcknowledgeAll(unacknowledgedIds);
}
}}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Confirmar todas
</button>
</div>
</div>
)}
</div>
);
};
export default StockAlertsPanel;

View File

@@ -1,180 +0,0 @@
import React from 'react';
import { AlertTriangle, Cloud, Package, Clock, ChevronRight } from 'lucide-react';
export interface Alert {
id: string;
type: 'stock' | 'weather' | 'order' | 'production' | 'system';
severity: 'high' | 'medium' | 'low';
title: string;
description: string;
action?: string;
time?: string;
}
interface CriticalAlertsProps {
alerts?: Alert[];
onAlertClick?: (alertId: string) => void;
className?: string;
}
const CriticalAlerts: React.FC<CriticalAlertsProps> = ({
alerts = [
{
id: '1',
type: 'stock',
severity: 'high',
title: 'Stock Bajo',
description: 'Pan integral: solo 5 unidades',
action: 'Hacer más',
time: '10:30'
},
{
id: '2',
type: 'weather',
severity: 'medium',
title: 'Lluvia Esperada',
description: 'Precipitaciones 14:00 - 17:00',
action: 'Ajustar producción',
time: '14:00'
},
{
id: '3',
type: 'order',
severity: 'low',
title: 'Pedido Especial',
description: 'Tarta de cumpleaños Ana - Viernes',
action: 'Ver detalles',
time: 'Viernes'
}
],
onAlertClick,
className = ''
}) => {
const getAlertIcon = (type: Alert['type']) => {
switch (type) {
case 'stock': return Package;
case 'weather': return Cloud;
case 'order': return Clock;
case 'production': return AlertTriangle;
default: return AlertTriangle;
}
};
const getAlertColors = (severity: Alert['severity']) => {
switch (severity) {
case 'high': return {
bg: 'bg-red-50 border-red-200',
icon: 'text-red-600',
title: 'text-red-900',
description: 'text-red-700'
};
case 'medium': return {
bg: 'bg-yellow-50 border-yellow-200',
icon: 'text-yellow-600',
title: 'text-yellow-900',
description: 'text-yellow-700'
};
case 'low': return {
bg: 'bg-blue-50 border-blue-200',
icon: 'text-blue-600',
title: 'text-blue-900',
description: 'text-blue-700'
};
}
};
const visibleAlerts = alerts.slice(0, 3); // Show max 3 alerts
return (
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<AlertTriangle className="h-5 w-5 mr-2 text-orange-600" />
Atención Requerida
</h3>
{alerts.length > 3 && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">
+{alerts.length - 3} más
</span>
)}
</div>
{/* Alerts List */}
{visibleAlerts.length > 0 ? (
<div className="space-y-3">
{visibleAlerts.map((alert) => {
const IconComponent = getAlertIcon(alert.type);
const colors = getAlertColors(alert.severity);
return (
<div
key={alert.id}
onClick={() => onAlertClick?.(alert.id)}
className={`${colors.bg} border rounded-lg p-3 cursor-pointer hover:shadow-sm transition-all duration-200`}
>
<div className="flex items-start space-x-3">
<IconComponent className={`h-4 w-4 mt-0.5 ${colors.icon} flex-shrink-0`} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className={`text-sm font-medium ${colors.title}`}>
{alert.title}
</h4>
<p className={`text-xs ${colors.description} mt-1`}>
{alert.description}
</p>
{alert.action && (
<p className={`text-xs ${colors.icon} font-medium mt-1`}>
{alert.action}
</p>
)}
</div>
<div className="flex items-center space-x-2 ml-3">
{alert.time && (
<span className={`text-xs ${colors.description}`}>
{alert.time}
</span>
)}
<ChevronRight className={`h-3 w-3 ${colors.icon}`} />
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-6">
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<AlertTriangle className="h-6 w-6 text-green-600" />
</div>
<h4 className="text-sm font-medium text-gray-900">Todo bajo control</h4>
<p className="text-xs text-gray-500 mt-1">No hay alertas que requieran atención</p>
</div>
)}
{/* Quick Summary */}
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-100">
<div className="flex items-center space-x-4 text-xs text-gray-500">
<span className="flex items-center">
<div className="w-2 h-2 bg-red-500 rounded-full mr-1"></div>
{alerts.filter(a => a.severity === 'high').length} Urgentes
</span>
<span className="flex items-center">
<div className="w-2 h-2 bg-yellow-500 rounded-full mr-1"></div>
{alerts.filter(a => a.severity === 'medium').length} Importantes
</span>
</div>
<button className="text-xs text-gray-600 hover:text-gray-900 font-medium">
Ver todas
</button>
</div>
</div>
);
};
export default CriticalAlerts;

View File

@@ -1,4 +1,3 @@
export { default as CriticalAlerts } from './CriticalAlerts';
export { default as OrderSuggestions } from './OrderSuggestions';
export { default as QuickActions } from './QuickActions';
export { default as QuickOverview } from './QuickOverview';

View File

@@ -1,155 +0,0 @@
import React from 'react';
import { AlertTriangle, TrendingDown, Package, Clock, Euro } from 'lucide-react';
export interface BusinessAlert {
id: string;
type: 'stockout_risk' | 'overstock' | 'revenue_loss' | 'quality_risk' | 'weather_impact';
severity: 'low' | 'medium' | 'high' | 'critical';
product: string;
message: string;
action: string;
impact?: {
type: 'revenue' | 'units' | 'percentage';
value: number;
currency?: string;
};
urgency?: 'immediate' | 'today' | 'this_week';
}
interface AlertCardProps {
alert: BusinessAlert;
onAction?: (alertId: string, actionType: string) => void;
}
const getSeverityConfig = (severity: BusinessAlert['severity']) => {
switch (severity) {
case 'critical':
return {
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
iconColor: 'text-red-600',
textColor: 'text-red-900',
actionColor: 'bg-red-100 hover:bg-red-200 text-red-800'
};
case 'high':
return {
bgColor: 'bg-orange-50',
borderColor: 'border-orange-200',
iconColor: 'text-orange-600',
textColor: 'text-orange-900',
actionColor: 'bg-orange-100 hover:bg-orange-200 text-orange-800'
};
case 'medium':
return {
bgColor: 'bg-yellow-50',
borderColor: 'border-yellow-200',
iconColor: 'text-yellow-600',
textColor: 'text-yellow-900',
actionColor: 'bg-yellow-100 hover:bg-yellow-200 text-yellow-800'
};
default:
return {
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
iconColor: 'text-blue-600',
textColor: 'text-blue-900',
actionColor: 'bg-blue-100 hover:bg-blue-200 text-blue-800'
};
}
};
const getAlertIcon = (type: BusinessAlert['type']) => {
switch (type) {
case 'stockout_risk':
return Package;
case 'overstock':
return TrendingDown;
case 'revenue_loss':
return Euro;
case 'quality_risk':
return Clock;
case 'weather_impact':
return AlertTriangle;
default:
return AlertTriangle;
}
};
const getUrgencyLabel = (urgency?: BusinessAlert['urgency']) => {
switch (urgency) {
case 'immediate':
return { label: 'URGENTE', color: 'bg-red-100 text-red-800' };
case 'today':
return { label: 'HOY', color: 'bg-orange-100 text-orange-800' };
case 'this_week':
return { label: 'ESTA SEMANA', color: 'bg-blue-100 text-blue-800' };
default:
return null;
}
};
const AlertCard: React.FC<AlertCardProps> = ({ alert, onAction }) => {
const config = getSeverityConfig(alert.severity);
const Icon = getAlertIcon(alert.type);
const urgencyInfo = getUrgencyLabel(alert.urgency);
const handleAction = () => {
onAction?.(alert.id, 'primary_action');
};
return (
<div className={`${config.bgColor} ${config.borderColor} border rounded-lg p-4 shadow-sm`}>
<div className="flex items-start space-x-3">
<div className={`${config.iconColor} mt-0.5`}>
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2">
<h4 className={`font-medium ${config.textColor}`}>
{alert.product}
</h4>
{urgencyInfo && (
<span className={`px-2 py-1 text-xs font-bold rounded ${urgencyInfo.color}`}>
{urgencyInfo.label}
</span>
)}
</div>
<p className={`text-sm mt-1 ${config.textColor.replace('900', '700')}`}>
{alert.message}
</p>
{alert.impact && (
<div className={`text-sm font-medium mt-2 ${config.textColor}`}>
{alert.impact.type === 'revenue' && (
<>Impacto: -{alert.impact.value}{alert.impact.currency || '€'}</>
)}
{alert.impact.type === 'units' && (
<>Unidades afectadas: {alert.impact.value}</>
)}
{alert.impact.type === 'percentage' && (
<>Reducción estimada: {alert.impact.value}%</>
)}
</div>
)}
</div>
</div>
<div className="mt-3">
<button
onClick={handleAction}
className={`px-3 py-2 text-sm font-medium rounded transition-colors ${config.actionColor}`}
>
{alert.action}
</button>
</div>
</div>
</div>
</div>
);
};
export default AlertCard;

View File

@@ -1,165 +0,0 @@
// Real API hook for Critical Alerts using backend forecast alerts
import { useState, useCallback, useEffect } from 'react';
import { useForecast } from '../api';
import { useTenantId } from './useTenantId';
export interface RealAlert {
id: string;
type: 'stock' | 'weather' | 'order' | 'production' | 'system';
severity: 'high' | 'medium' | 'low';
title: string;
description: string;
action?: string;
time: string;
}
export const useRealAlerts = () => {
const [alerts, setAlerts] = useState<RealAlert[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { tenantId } = useTenantId();
const { getForecastAlerts, acknowledgeForecastAlert } = useForecast();
// Transform backend forecast alerts to frontend alert format
const transformForecastAlert = (alert: any): RealAlert => {
// Map alert types
let type: RealAlert['type'] = 'system';
if (alert.alert_type?.includes('stock') || alert.alert_type?.includes('demand')) {
type = 'stock';
} else if (alert.alert_type?.includes('weather')) {
type = 'weather';
} else if (alert.alert_type?.includes('production')) {
type = 'production';
}
// Map severity
let severity: RealAlert['severity'] = 'medium';
if (alert.severity === 'critical' || alert.severity === 'high') {
severity = 'high';
} else if (alert.severity === 'low') {
severity = 'low';
}
// Generate user-friendly title and description
let title = alert.message;
let description = alert.message;
if (alert.alert_type?.includes('high_demand')) {
title = 'Alta Demanda Prevista';
description = `Se prevé alta demanda. ${alert.message}`;
} else if (alert.alert_type?.includes('low_confidence')) {
title = 'Predicción Incierta';
description = `Baja confianza en predicción. ${alert.message}`;
} else if (alert.alert_type?.includes('stock_risk')) {
title = 'Riesgo de Desabastecimiento';
description = `Posible falta de stock. ${alert.message}`;
}
return {
id: alert.id,
type,
severity,
title,
description,
action: 'Ver detalles',
time: new Date(alert.created_at).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
})
};
};
// Load real alerts from backend
const loadAlerts = useCallback(async () => {
if (!tenantId) return;
setIsLoading(true);
setError(null);
try {
// Get forecast alerts from backend
const response = await getForecastAlerts(tenantId);
// Extract alerts array from paginated response
const forecastAlerts = response.alerts || [];
// Filter only active alerts
const activeAlerts = forecastAlerts.filter(alert => alert.is_active);
// Transform to frontend format
const transformedAlerts = activeAlerts.map(transformForecastAlert);
// Sort by severity and time (most recent first)
transformedAlerts.sort((a, b) => {
// First by severity (high > medium > low)
const severityOrder = { high: 3, medium: 2, low: 1 };
const severityDiff = severityOrder[b.severity] - severityOrder[a.severity];
if (severityDiff !== 0) return severityDiff;
// Then by time (most recent first)
return b.time.localeCompare(a.time);
});
setAlerts(transformedAlerts.slice(0, 3)); // Show max 3 alerts in dashboard
} catch (error) {
console.error('Error loading alerts:', error);
setError(error instanceof Error ? error.message : 'Failed to load alerts');
// Fallback to sample alerts based on common scenarios
setAlerts(generateFallbackAlerts());
} finally {
setIsLoading(false);
}
}, [tenantId, getForecastAlerts]);
// Handle alert acknowledgment
const handleAlertAction = useCallback(async (alertId: string) => {
if (!tenantId) return;
try {
await acknowledgeForecastAlert(tenantId, alertId);
// Remove acknowledged alert from local state
setAlerts(prev => prev.filter(alert => alert.id !== alertId));
} catch (error) {
console.error('Error acknowledging alert:', error);
}
}, [tenantId, acknowledgeForecastAlert]);
// Generate fallback alerts when API fails
const generateFallbackAlerts = (): RealAlert[] => {
const now = new Date();
const timeString = now.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
return [
{
id: 'fallback-1',
type: 'stock',
severity: 'high',
title: 'Stock Bajo de Croissants',
description: 'Se prevé alta demanda este fin de semana',
action: 'Aumentar producción',
time: timeString
}
];
};
// Load alerts on mount and when tenant changes
useEffect(() => {
loadAlerts();
// Refresh alerts every 5 minutes
const interval = setInterval(loadAlerts, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [loadAlerts]);
return {
alerts,
isLoading,
error,
onAlertAction: handleAlertAction,
reload: loadAlerts,
clearError: () => setError(null),
};
};

View File

@@ -1,11 +1,9 @@
import React from 'react';
import { useDashboard } from '../../hooks/useDashboard';
import { useOrderSuggestions } from '../../hooks/useOrderSuggestions';
import { useRealAlerts } from '../../hooks/useRealAlerts';
// Import simplified components
import TodayRevenue from '../../components/simple/TodayRevenue';
import CriticalAlerts from '../../components/simple/CriticalAlerts';
import TodayProduction from '../../components/simple/TodayProduction';
import QuickActions from '../../components/simple/QuickActions';
import QuickOverview from '../../components/simple/QuickOverview';
@@ -55,11 +53,6 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
error: ordersError
});
// Use real API data for alerts
const {
alerts: realAlerts,
onAlertAction
} = useRealAlerts();
// Transform forecast data for production component
@@ -163,11 +156,6 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
dailyTarget={350}
/>
{/* Alerts - Real API Data */}
<CriticalAlerts
alerts={realAlerts}
onAlertClick={onAlertAction}
/>
{/* Quick Actions - Easy Access */}
<QuickActions
@@ -243,15 +231,13 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
{/* Success Message - When Everything is Good */}
{realAlerts.length === 0 && (
<div className="bg-green-50 border border-green-200 rounded-xl p-6 text-center">
<div className="text-4xl mb-2">🎉</div>
<h4 className="font-medium text-green-900">¡Todo bajo control!</h4>
<p className="text-green-700 text-sm mt-1">
No hay alertas activas. Tu panadería está funcionando perfectamente.
</p>
</div>
)}
<div className="bg-green-50 border border-green-200 rounded-xl p-6 text-center">
<div className="text-4xl mb-2">🎉</div>
<h4 className="font-medium text-green-900">¡Todo bajo control!</h4>
<p className="text-green-700 text-sm mt-1">
Tu panadería está funcionando perfectamente.
</p>
</div>
</div>
);
};

View File

@@ -28,7 +28,6 @@ import {
} from '../../api/services/inventory.service';
import InventoryItemCard from '../../components/inventory/InventoryItemCard';
import StockAlertsPanel from '../../components/inventory/StockAlertsPanel';
type ViewMode = 'grid' | 'list';
@@ -51,7 +50,6 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
const {
items,
stockLevels,
alerts,
dashboardData,
isLoading,
error,
@@ -61,14 +59,12 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
updateItem,
deleteItem,
adjustStock,
acknowledgeAlert,
refresh,
clearError
} = useInventory();
// Local state
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [showAlerts, setShowAlerts] = useState(false);
const [filters, setFilters] = useState<FilterState>({
search: '',
sort_by: 'name',
@@ -155,24 +151,12 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
}
};
// Handle alert acknowledgment
const handleAcknowledgeAlert = async (alertId: string) => {
await acknowledgeAlert(alertId);
};
// Handle bulk acknowledge alerts
const handleBulkAcknowledgeAlerts = async (alertIds: string[]) => {
// TODO: Implement bulk acknowledge
for (const alertId of alertIds) {
await acknowledgeAlert(alertId);
}
};
// Get quick stats
const getQuickStats = () => {
const totalItems = items.length;
const lowStockItems = alerts.filter(a => a.alert_type === 'low_stock' && !a.is_acknowledged).length;
const expiringItems = alerts.filter(a => a.alert_type === 'expiring_soon' && !a.is_acknowledged).length;
const lowStockItems = 0;
const expiringItems = 0;
const totalValue = dashboardData?.total_value || 0;
return { totalItems, lowStockItems, expiringItems, totalValue };
@@ -195,19 +179,6 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => setShowAlerts(!showAlerts)}
className={`relative p-2 rounded-lg transition-colors ${
showAlerts ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<AlertTriangle className="w-5 h-5" />
{alerts.filter(a => !a.is_acknowledged).length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{alerts.filter(a => !a.is_acknowledged).length}
</span>
)}
</button>
<button
onClick={() => refresh()}
@@ -230,7 +201,7 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Main Content */}
<div className={`${showAlerts ? 'lg:col-span-3' : 'lg:col-span-4'}`}>
<div className="lg:col-span-4">
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg border">
@@ -534,17 +505,6 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
)}
</div>
{/* Alerts Panel */}
{showAlerts && (
<div className="lg:col-span-1">
<StockAlertsPanel
alerts={alerts}
onAcknowledge={handleAcknowledgeAlert}
onAcknowledgeAll={handleBulkAcknowledgeAlerts}
onViewItem={handleViewItemById}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -38,18 +38,6 @@ export interface Forecast {
features_used?: Record<string, any>;
}
export interface ForecastAlert {
id: string;
tenant_id: string;
type: 'high_demand' | 'low_demand' | 'stockout_risk' | 'overproduction';
inventory_product_id: string; // Reference to inventory service product
product_name?: string; // Optional - for display, populated by frontend from inventory service
message: string;
severity: 'low' | 'medium' | 'high';
created_at: string;
acknowledged: boolean;
forecast_id?: string;
}
export interface QuickForecast {
product: string;
@@ -63,12 +51,10 @@ export interface QuickForecast {
interface ForecastState {
forecasts: Forecast[];
todayForecasts: QuickForecast[];
alerts: ForecastAlert[];
// Loading states
isLoading: boolean;
isGeneratingForecast: boolean;
isFetchingAlerts: boolean;
// Selected filters
selectedDate: string;
@@ -93,10 +79,8 @@ interface ForecastState {
const initialState: ForecastState = {
forecasts: [],
todayForecasts: [],
alerts: [],
isLoading: false,
isGeneratingForecast: false,
isFetchingAlerts: false,
selectedDate: new Date().toISOString().split('T')[0],
selectedProduct: 'all',
selectedLocation: '',
@@ -215,40 +199,6 @@ export const fetchTodayForecasts = createAsyncThunk(
}
);
export const fetchForecastAlerts = createAsyncThunk(
'forecast/fetchAlerts',
async (tenantId: string) => {
const response = await fetch(`/api/v1/tenants/${tenantId}/forecasts/alerts`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch forecast alerts');
}
return response.json();
}
);
export const acknowledgeAlert = createAsyncThunk(
'forecast/acknowledgeAlert',
async ({ tenantId, alertId }: { tenantId: string; alertId: string }) => {
const response = await fetch(`/api/v1/tenants/${tenantId}/forecasts/alerts/${alertId}/acknowledge`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to acknowledge alert');
}
return { alertId };
}
);
export const fetchWeatherContext = createAsyncThunk(
'forecast/fetchWeather',
@@ -293,12 +243,6 @@ const forecastSlice = createSlice({
setCurrentWeather: (state, action: PayloadAction<ForecastState['currentWeather']>) => {
state.currentWeather = action.payload;
},
markAlertAsRead: (state, action: PayloadAction<string>) => {
const alert = state.alerts.find(a => a.id === action.payload);
if (alert) {
alert.acknowledged = true;
}
},
clearForecasts: (state) => {
state.forecasts = [];
state.todayForecasts = [];
@@ -378,28 +322,6 @@ const forecastSlice = createSlice({
state.error = action.error.message || 'Failed to fetch today\'s forecasts';
})
// Fetch alerts
builder
.addCase(fetchForecastAlerts.pending, (state) => {
state.isFetchingAlerts = true;
})
.addCase(fetchForecastAlerts.fulfilled, (state, action) => {
state.isFetchingAlerts = false;
state.alerts = action.payload.alerts || [];
})
.addCase(fetchForecastAlerts.rejected, (state, action) => {
state.isFetchingAlerts = false;
state.error = action.error.message || 'Failed to fetch alerts';
})
// Acknowledge alert
builder
.addCase(acknowledgeAlert.fulfilled, (state, action) => {
const alert = state.alerts.find(a => a.id === action.payload.alertId);
if (alert) {
alert.acknowledged = true;
}
})
// Fetch weather context
builder
@@ -421,7 +343,6 @@ export const {
addForecast,
updateModelAccuracy,
setCurrentWeather,
markAlertAsRead,
clearForecasts,
} = forecastSlice.actions;
@@ -430,7 +351,6 @@ export default forecastSlice.reducer;
// Selectors
export const selectForecasts = (state: { forecast: ForecastState }) => state.forecast.forecasts;
export const selectTodayForecasts = (state: { forecast: ForecastState }) => state.forecast.todayForecasts;
export const selectForecastAlerts = (state: { forecast: ForecastState }) => state.forecast.alerts;
export const selectForecastLoading = (state: { forecast: ForecastState }) => state.forecast.isLoading;
export const selectForecastGenerating = (state: { forecast: ForecastState }) => state.forecast.isGeneratingForecast;
export const selectForecastError = (state: { forecast: ForecastState }) => state.forecast.error;
@@ -441,5 +361,3 @@ export const selectForecastFilters = (state: { forecast: ForecastState }) => ({
});
export const selectCurrentWeather = (state: { forecast: ForecastState }) => state.forecast.currentWeather;
export const selectModelAccuracy = (state: { forecast: ForecastState }) => state.forecast.modelAccuracy;
export const selectUnacknowledgedAlerts = (state: { forecast: ForecastState }) =>
state.forecast.alerts.filter(alert => !alert.acknowledged);

View File

@@ -12,7 +12,7 @@ import uuid
from app.services.forecasting_service import EnhancedForecastingService
from app.schemas.forecasts import (
ForecastRequest, ForecastResponse, BatchForecastRequest,
BatchForecastResponse, AlertResponse
BatchForecastResponse
)
from shared.auth.decorators import (
get_current_user_dep,
@@ -242,68 +242,6 @@ async def get_enhanced_tenant_forecasts(
)
@router.get("/tenants/{tenant_id}/forecasts/alerts")
@track_execution_time("enhanced_get_alerts_duration_seconds", "forecasting-service")
async def get_enhanced_forecast_alerts(
tenant_id: str = Path(..., description="Tenant ID"),
active_only: bool = Query(True, description="Return only active alerts"),
skip: int = Query(0, description="Number of records to skip"),
limit: int = Query(50, description="Number of records to return"),
request_obj: Request = None,
current_tenant: str = Depends(get_current_tenant_id_dep),
enhanced_forecasting_service: EnhancedForecastingService = Depends(get_enhanced_forecasting_service)
):
"""Get forecast alerts using enhanced repository pattern"""
metrics = get_metrics_collector(request_obj)
try:
# Enhanced tenant validation
if tenant_id != current_tenant:
if metrics:
metrics.increment_counter("enhanced_get_alerts_access_denied_total")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to tenant resources"
)
# Record metrics
if metrics:
metrics.increment_counter("enhanced_get_alerts_total")
# Get alerts using enhanced service
alerts = await enhanced_forecasting_service.get_tenant_alerts(
tenant_id=tenant_id,
active_only=active_only,
skip=skip,
limit=limit
)
if metrics:
metrics.increment_counter("enhanced_get_alerts_success_total")
return {
"tenant_id": tenant_id,
"alerts": alerts,
"total_returned": len(alerts),
"active_only": active_only,
"pagination": {
"skip": skip,
"limit": limit
},
"enhanced_features": True,
"repository_integration": True
}
except Exception as e:
if metrics:
metrics.increment_counter("enhanced_get_alerts_errors_total")
logger.error("Failed to get enhanced forecast alerts",
tenant_id=tenant_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get forecast alerts"
)
@router.get("/tenants/{tenant_id}/forecasts/{forecast_id}")

View File

@@ -51,9 +51,5 @@ class ForecastingSettings(BaseServiceSettings):
TEMPERATURE_THRESHOLD_HOT: float = float(os.getenv("TEMPERATURE_THRESHOLD_HOT", "30.0"))
RAIN_IMPACT_FACTOR: float = float(os.getenv("RAIN_IMPACT_FACTOR", "0.7"))
# Alert Thresholds
HIGH_DEMAND_THRESHOLD: float = float(os.getenv("HIGH_DEMAND_THRESHOLD", "1.5"))
LOW_DEMAND_THRESHOLD: float = float(os.getenv("LOW_DEMAND_THRESHOLD", "0.5"))
STOCKOUT_RISK_THRESHOLD: float = float(os.getenv("STOCKOUT_RISK_THRESHOLD", "0.9"))
settings = ForecastingSettings()

View File

@@ -86,29 +86,4 @@ class PredictionBatch(Base):
def __repr__(self):
return f"<PredictionBatch(id={self.id}, status={self.status})>"
class ForecastAlert(Base):
"""Alerts based on forecast results"""
__tablename__ = "forecast_alerts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
forecast_id = Column(UUID(as_uuid=True), nullable=False)
# Alert information
alert_type = Column(String(50), nullable=False) # high_demand, low_demand, stockout_risk
severity = Column(String(20), default="medium") # low, medium, high, critical
message = Column(Text, nullable=False)
# Status
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
acknowledged_at = Column(DateTime(timezone=True))
resolved_at = Column(DateTime(timezone=True))
is_active = Column(Boolean, default=True)
# Notification
notification_sent = Column(Boolean, default=False)
notification_method = Column(String(50)) # email, whatsapp, sms
def __repr__(self):
return f"<ForecastAlert(id={self.id}, type={self.alert_type})>"

View File

@@ -6,7 +6,6 @@ Repository implementations for forecasting service
from .base import ForecastingBaseRepository
from .forecast_repository import ForecastRepository
from .prediction_batch_repository import PredictionBatchRepository
from .forecast_alert_repository import ForecastAlertRepository
from .performance_metric_repository import PerformanceMetricRepository
from .prediction_cache_repository import PredictionCacheRepository
@@ -14,7 +13,6 @@ __all__ = [
"ForecastingBaseRepository",
"ForecastRepository",
"PredictionBatchRepository",
"ForecastAlertRepository",
"PerformanceMetricRepository",
"PredictionCacheRepository"
]

View File

@@ -1,375 +0,0 @@
"""
Forecast Alert Repository
Repository for forecast alert operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from datetime import datetime, timedelta
import structlog
from .base import ForecastingBaseRepository
from app.models.forecasts import ForecastAlert
from shared.database.exceptions import DatabaseError, ValidationError
logger = structlog.get_logger()
class ForecastAlertRepository(ForecastingBaseRepository):
"""Repository for forecast alert operations"""
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 300):
# Alerts change frequently, shorter cache time (5 minutes)
super().__init__(ForecastAlert, session, cache_ttl)
async def create_alert(self, alert_data: Dict[str, Any]) -> ForecastAlert:
"""Create a new forecast alert"""
try:
# Validate alert data
validation_result = self._validate_forecast_data(
alert_data,
["tenant_id", "forecast_id", "alert_type", "message"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid alert data: {validation_result['errors']}")
# Set default values
if "severity" not in alert_data:
alert_data["severity"] = "medium"
if "is_active" not in alert_data:
alert_data["is_active"] = True
if "notification_sent" not in alert_data:
alert_data["notification_sent"] = False
alert = await self.create(alert_data)
logger.info("Forecast alert created",
alert_id=alert.id,
tenant_id=alert.tenant_id,
alert_type=alert.alert_type,
severity=alert.severity)
return alert
except ValidationError:
raise
except Exception as e:
logger.error("Failed to create forecast alert",
tenant_id=alert_data.get("tenant_id"),
error=str(e))
raise DatabaseError(f"Failed to create alert: {str(e)}")
async def get_active_alerts(
self,
tenant_id: str,
alert_type: str = None,
severity: str = None
) -> List[ForecastAlert]:
"""Get active alerts for a tenant"""
try:
filters = {
"tenant_id": tenant_id,
"is_active": True
}
if alert_type:
filters["alert_type"] = alert_type
if severity:
filters["severity"] = severity
return await self.get_multi(
filters=filters,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get active alerts",
tenant_id=tenant_id,
error=str(e))
return []
async def acknowledge_alert(
self,
alert_id: str,
acknowledged_by: str = None
) -> Optional[ForecastAlert]:
"""Acknowledge an alert"""
try:
update_data = {
"acknowledged_at": datetime.utcnow()
}
if acknowledged_by:
# Store in message or create a new field if needed
current_alert = await self.get_by_id(alert_id)
if current_alert:
update_data["message"] = f"{current_alert.message} (Acknowledged by: {acknowledged_by})"
updated_alert = await self.update(alert_id, update_data)
logger.info("Alert acknowledged",
alert_id=alert_id,
acknowledged_by=acknowledged_by)
return updated_alert
except Exception as e:
logger.error("Failed to acknowledge alert",
alert_id=alert_id,
error=str(e))
raise DatabaseError(f"Failed to acknowledge alert: {str(e)}")
async def resolve_alert(
self,
alert_id: str,
resolved_by: str = None
) -> Optional[ForecastAlert]:
"""Resolve an alert"""
try:
update_data = {
"resolved_at": datetime.utcnow(),
"is_active": False
}
if resolved_by:
current_alert = await self.get_by_id(alert_id)
if current_alert:
update_data["message"] = f"{current_alert.message} (Resolved by: {resolved_by})"
updated_alert = await self.update(alert_id, update_data)
logger.info("Alert resolved",
alert_id=alert_id,
resolved_by=resolved_by)
return updated_alert
except Exception as e:
logger.error("Failed to resolve alert",
alert_id=alert_id,
error=str(e))
raise DatabaseError(f"Failed to resolve alert: {str(e)}")
async def mark_notification_sent(
self,
alert_id: str,
notification_method: str
) -> Optional[ForecastAlert]:
"""Mark alert notification as sent"""
try:
update_data = {
"notification_sent": True,
"notification_method": notification_method
}
updated_alert = await self.update(alert_id, update_data)
logger.debug("Alert notification marked as sent",
alert_id=alert_id,
method=notification_method)
return updated_alert
except Exception as e:
logger.error("Failed to mark notification as sent",
alert_id=alert_id,
error=str(e))
return None
async def get_unnotified_alerts(self, tenant_id: str = None) -> List[ForecastAlert]:
"""Get alerts that haven't been notified yet"""
try:
filters = {
"is_active": True,
"notification_sent": False
}
if tenant_id:
filters["tenant_id"] = tenant_id
return await self.get_multi(
filters=filters,
order_by="created_at",
order_desc=False # Oldest first for notification
)
except Exception as e:
logger.error("Failed to get unnotified alerts",
tenant_id=tenant_id,
error=str(e))
return []
async def get_alert_statistics(self, tenant_id: str) -> Dict[str, Any]:
"""Get alert statistics for a tenant"""
try:
# Get counts by type
type_query = text("""
SELECT alert_type, COUNT(*) as count
FROM forecast_alerts
WHERE tenant_id = :tenant_id
GROUP BY alert_type
ORDER BY count DESC
""")
result = await self.session.execute(type_query, {"tenant_id": tenant_id})
alerts_by_type = {row.alert_type: row.count for row in result.fetchall()}
# Get counts by severity
severity_query = text("""
SELECT severity, COUNT(*) as count
FROM forecast_alerts
WHERE tenant_id = :tenant_id
GROUP BY severity
ORDER BY count DESC
""")
severity_result = await self.session.execute(severity_query, {"tenant_id": tenant_id})
alerts_by_severity = {row.severity: row.count for row in severity_result.fetchall()}
# Get status counts
total_alerts = await self.count(filters={"tenant_id": tenant_id})
active_alerts = await self.count(filters={
"tenant_id": tenant_id,
"is_active": True
})
acknowledged_alerts = await self.count(filters={
"tenant_id": tenant_id,
"acknowledged_at": "IS NOT NULL" # This won't work with our current filters
})
# Get recent activity (alerts in last 7 days)
seven_days_ago = datetime.utcnow() - timedelta(days=7)
recent_alerts = len(await self.get_by_date_range(
tenant_id, seven_days_ago, datetime.utcnow(), limit=1000
))
# Calculate response metrics
response_query = text("""
SELECT
AVG(EXTRACT(EPOCH FROM (acknowledged_at - created_at))/60) as avg_acknowledgment_time_minutes,
AVG(EXTRACT(EPOCH FROM (resolved_at - created_at))/60) as avg_resolution_time_minutes,
COUNT(CASE WHEN acknowledged_at IS NOT NULL THEN 1 END) as acknowledged_count,
COUNT(CASE WHEN resolved_at IS NOT NULL THEN 1 END) as resolved_count
FROM forecast_alerts
WHERE tenant_id = :tenant_id
""")
response_result = await self.session.execute(response_query, {"tenant_id": tenant_id})
response_row = response_result.fetchone()
return {
"total_alerts": total_alerts,
"active_alerts": active_alerts,
"resolved_alerts": total_alerts - active_alerts,
"alerts_by_type": alerts_by_type,
"alerts_by_severity": alerts_by_severity,
"recent_alerts_7d": recent_alerts,
"response_metrics": {
"avg_acknowledgment_time_minutes": float(response_row.avg_acknowledgment_time_minutes or 0),
"avg_resolution_time_minutes": float(response_row.avg_resolution_time_minutes or 0),
"acknowledgment_rate": round((response_row.acknowledged_count / max(total_alerts, 1)) * 100, 2),
"resolution_rate": round((response_row.resolved_count / max(total_alerts, 1)) * 100, 2)
} if response_row else {
"avg_acknowledgment_time_minutes": 0.0,
"avg_resolution_time_minutes": 0.0,
"acknowledgment_rate": 0.0,
"resolution_rate": 0.0
}
}
except Exception as e:
logger.error("Failed to get alert statistics",
tenant_id=tenant_id,
error=str(e))
return {
"total_alerts": 0,
"active_alerts": 0,
"resolved_alerts": 0,
"alerts_by_type": {},
"alerts_by_severity": {},
"recent_alerts_7d": 0,
"response_metrics": {
"avg_acknowledgment_time_minutes": 0.0,
"avg_resolution_time_minutes": 0.0,
"acknowledgment_rate": 0.0,
"resolution_rate": 0.0
}
}
async def cleanup_old_alerts(self, days_old: int = 90) -> int:
"""Clean up old resolved alerts"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
query_text = """
DELETE FROM forecast_alerts
WHERE is_active = false
AND resolved_at IS NOT NULL
AND resolved_at < :cutoff_date
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info("Cleaned up old forecast alerts",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup old alerts",
error=str(e))
raise DatabaseError(f"Alert cleanup failed: {str(e)}")
async def bulk_resolve_alerts(
self,
tenant_id: str,
alert_type: str = None,
older_than_hours: int = 24
) -> int:
"""Bulk resolve old alerts"""
try:
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
conditions = [
"tenant_id = :tenant_id",
"is_active = true",
"created_at < :cutoff_time"
]
params = {
"tenant_id": tenant_id,
"cutoff_time": cutoff_time
}
if alert_type:
conditions.append("alert_type = :alert_type")
params["alert_type"] = alert_type
query_text = f"""
UPDATE forecast_alerts
SET is_active = false, resolved_at = :resolved_at
WHERE {' AND '.join(conditions)}
"""
params["resolved_at"] = datetime.utcnow()
result = await self.session.execute(text(query_text), params)
resolved_count = result.rowcount
logger.info("Bulk resolved old alerts",
tenant_id=tenant_id,
alert_type=alert_type,
resolved_count=resolved_count,
older_than_hours=older_than_hours)
return resolved_count
except Exception as e:
logger.error("Failed to bulk resolve alerts",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Bulk resolve failed: {str(e)}")

View File

@@ -14,11 +14,6 @@ class BusinessType(str, Enum):
INDIVIDUAL = "individual"
CENTRAL_WORKSHOP = "central_workshop"
class AlertType(str, Enum):
HIGH_DEMAND = "high_demand"
LOW_DEMAND = "low_demand"
STOCKOUT_RISK = "stockout_risk"
OVERPRODUCTION = "overproduction"
class ForecastRequest(BaseModel):
"""Request schema for generating forecasts"""
@@ -100,16 +95,4 @@ class BatchForecastResponse(BaseModel):
forecasts: Optional[List[ForecastResponse]]
error_message: Optional[str]
class AlertResponse(BaseModel):
"""Response schema for forecast alerts"""
id: str
tenant_id: str
forecast_id: str
alert_type: str
severity: str
message: str
is_active: bool
created_at: datetime
acknowledged_at: Optional[datetime]
notification_sent: bool

View File

@@ -10,7 +10,6 @@ from .data_client import DataClient
from .messaging import (
publish_forecast_generated,
publish_batch_forecast_completed,
publish_forecast_alert,
ForecastingStatusPublisher
)
@@ -22,6 +21,5 @@ __all__ = [
"DataClient",
"publish_forecast_generated",
"publish_batch_forecast_completed",
"publish_forecast_alert",
"ForecastingStatusPublisher"
]

View File

@@ -18,7 +18,6 @@ from app.services.data_client import DataClient
from app.repositories import (
ForecastRepository,
PredictionBatchRepository,
ForecastAlertRepository,
PerformanceMetricRepository,
PredictionCacheRepository
)
@@ -36,7 +35,7 @@ logger = structlog.get_logger()
class EnhancedForecastingService:
"""
Enhanced forecasting service using repository pattern.
Handles forecast generation, batch processing, and alerting with proper data abstraction.
Handles forecast generation, batch processing with proper data abstraction.
"""
def __init__(self, database_manager=None):
@@ -55,7 +54,6 @@ class EnhancedForecastingService:
return {
'forecast': ForecastRepository(session),
'batch': PredictionBatchRepository(session),
'alert': ForecastAlertRepository(session),
'performance': PerformanceMetricRepository(session),
'cache': PredictionCacheRepository(session)
}
@@ -165,15 +163,6 @@ class EnhancedForecastingService:
logger.error("Failed to delete forecast", error=str(e))
return False
async def get_tenant_alerts(self, tenant_id: str, active_only: bool = True,
skip: int = 0, limit: int = 50) -> List[Dict]:
"""Get tenant alerts"""
try:
# Implementation would use repository pattern
return []
except Exception as e:
logger.error("Failed to get tenant alerts", error=str(e))
raise
async def get_tenant_forecast_statistics(self, tenant_id: str) -> Dict[str, Any]:
"""Get tenant forecast statistics"""
@@ -246,7 +235,7 @@ class EnhancedForecastingService:
request: ForecastRequest
) -> ForecastResponse:
"""
Generate forecast using repository pattern with caching and alerting.
Generate forecast using repository pattern with caching.
"""
start_time = datetime.utcnow()
@@ -339,8 +328,6 @@ class EnhancedForecastingService:
expires_in_hours=24
)
# Step 8: Check for alerts
await self._check_and_create_alerts(forecast, adjusted_prediction, repos)
logger.info("Enhanced forecast generated successfully",
forecast_id=forecast.id,
@@ -398,8 +385,6 @@ class EnhancedForecastingService:
# Get forecast summary
forecast_summary = await repos['forecast'].get_forecast_summary(tenant_id)
# Get alert statistics
alert_stats = await repos['alert'].get_alert_statistics(tenant_id)
# Get batch statistics
batch_stats = await repos['batch'].get_batch_statistics(tenant_id)
@@ -415,7 +400,6 @@ class EnhancedForecastingService:
return {
"tenant_id": tenant_id,
"forecast_analytics": forecast_summary,
"alert_analytics": alert_stats,
"batch_analytics": batch_stats,
"cache_performance": cache_stats,
"performance_trends": performance_trends,
@@ -469,51 +453,6 @@ class EnhancedForecastingService:
error=str(e))
raise DatabaseError(f"Failed to create batch: {str(e)}")
async def _check_and_create_alerts(self, forecast, prediction: Dict[str, Any], repos: Dict):
"""Check forecast results and create alerts if necessary"""
try:
alerts_to_create = []
# Check for high demand alert
if prediction['prediction'] > 100: # Threshold for high demand
alerts_to_create.append({
"tenant_id": str(forecast.tenant_id),
"forecast_id": str(forecast.id), # Convert UUID to string
"alert_type": "high_demand",
"severity": "high" if prediction['prediction'] > 200 else "medium",
"message": f"High demand predicted for inventory product {str(forecast.inventory_product_id)}: {prediction['prediction']:.1f} units"
})
# Check for low demand alert
elif prediction['prediction'] < 10: # Threshold for low demand
alerts_to_create.append({
"tenant_id": str(forecast.tenant_id),
"forecast_id": str(forecast.id), # Convert UUID to string
"alert_type": "low_demand",
"severity": "low",
"message": f"Low demand predicted for inventory product {str(forecast.inventory_product_id)}: {prediction['prediction']:.1f} units"
})
# Check for stockout risk (very low prediction with narrow confidence interval)
confidence_interval = prediction['upper_bound'] - prediction['lower_bound']
if prediction['prediction'] < 5 and confidence_interval < 10:
alerts_to_create.append({
"tenant_id": str(forecast.tenant_id),
"forecast_id": str(forecast.id), # Convert UUID to string
"alert_type": "stockout_risk",
"severity": "critical",
"message": f"Stockout risk for inventory product {str(forecast.inventory_product_id)}: predicted {prediction['prediction']:.1f} units with high confidence"
})
# Create alerts
for alert_data in alerts_to_create:
await repos['alert'].create_alert(alert_data)
except Exception as e:
logger.error("Failed to create alerts",
forecast_id=forecast.id,
error=str(e))
# Don't raise - alerts are not critical for forecast generation
def _create_forecast_response_from_cache(self, cache_entry) -> ForecastResponse:
"""Create forecast response from cached entry"""

View File

@@ -72,12 +72,6 @@ async def publish_forecast_completed(data: Dict[str, Any]):
event = ForecastGeneratedEvent(service_name="forecasting_service", data=data, event_type="forecast.completed")
await rabbitmq_client.publish_forecast_event(event_type="completed", forecast_data=event.to_dict())
async def publish_alert_created(data: Dict[str, Any]):
"""Publish alert created event"""
# Assuming 'alert.created' is a type of forecast event, or define a new exchange/publisher method
if rabbitmq_client:
event = ForecastGeneratedEvent(service_name="forecasting_service", data=data, event_type="alert.created")
await rabbitmq_client.publish_forecast_event(event_type="alert.created", forecast_data=event.to_dict())
async def publish_batch_completed(data: Dict[str, Any]):
"""Publish batch forecast completed event"""
@@ -181,19 +175,6 @@ async def publish_batch_forecast_completed(data: dict) -> bool:
logger.error("Failed to publish batch forecast event", error=str(e))
return False
async def publish_forecast_alert(data: dict) -> bool:
"""Publish forecast alert event"""
try:
if rabbitmq_client:
await rabbitmq_client.publish_event(
exchange="forecasting_events",
routing_key="forecast.alert",
message=data
)
return True
except Exception as e:
logger.error("Failed to publish forecast alert event", error=str(e))
return False
# Publisher class for compatibility

View File

@@ -12,7 +12,6 @@ from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
from shared.notifications.alert_integration import AlertIntegration
from shared.database.transactions import transactional
from app.core.config import settings
@@ -45,7 +44,7 @@ class FoodSafetyService:
"""Service for food safety and compliance operations"""
def __init__(self):
self.alert_integration = AlertIntegration()
pass
# ===== COMPLIANCE MANAGEMENT =====

View File

@@ -48,11 +48,6 @@ class OrdersSettings(BaseServiceSettings):
MAX_ORDER_VALUE: float = float(os.getenv("MAX_ORDER_VALUE", "100000.0"))
VALIDATE_PRODUCT_AVAILABILITY: bool = os.getenv("VALIDATE_PRODUCT_AVAILABILITY", "true").lower() == "true"
# Alert Thresholds
HIGH_VALUE_ORDER_THRESHOLD: float = float(os.getenv("HIGH_VALUE_ORDER_THRESHOLD", "5000.0"))
LARGE_QUANTITY_ORDER_THRESHOLD: int = int(os.getenv("LARGE_QUANTITY_ORDER_THRESHOLD", "100"))
RUSH_ORDER_HOURS_THRESHOLD: int = int(os.getenv("RUSH_ORDER_HOURS_THRESHOLD", "24"))
PROCUREMENT_SHORTAGE_THRESHOLD: float = float(os.getenv("PROCUREMENT_SHORTAGE_THRESHOLD", "90.0"))
# Payment and Pricing
PAYMENT_VALIDATION_ENABLED: bool = os.getenv("PAYMENT_VALIDATION_ENABLED", "true").lower() == "true"

View File

@@ -58,7 +58,6 @@ async def init_database():
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
from app.models.customer import Customer, CustomerContact
from app.models.procurement import ProcurementPlan, ProcurementRequirement
from app.models.alerts import OrderAlert
# Create all tables
await conn.run_sync(Base.metadata.create_all)

View File

@@ -1,144 +0,0 @@
# ================================================================
# services/orders/app/models/alerts.py
# ================================================================
"""
Alert system database models for Orders Service
"""
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Optional
from sqlalchemy import Column, String, Boolean, DateTime, Numeric, Text, Integer
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from app.core.database import Base
class OrderAlert(Base):
"""Alert system for orders and procurement issues"""
__tablename__ = "order_alerts"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
alert_code = Column(String(50), nullable=False, index=True)
# Alert categorization
alert_type = Column(String(50), nullable=False, index=True)
# Alert types: order_issue, procurement_shortage, payment_problem, delivery_delay,
# quality_concern, high_value_order, rush_order, customer_issue, supplier_problem
severity = Column(String(20), nullable=False, default="medium", index=True)
# Severity levels: critical, high, medium, low
category = Column(String(50), nullable=False, index=True)
# Categories: operational, financial, quality, customer, supplier, compliance
# Alert source and context
source_entity_type = Column(String(50), nullable=False) # order, customer, procurement_plan, etc.
source_entity_id = Column(UUID(as_uuid=True), nullable=False, index=True)
source_entity_reference = Column(String(100), nullable=True) # Human-readable reference
# Alert content
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
detailed_message = Column(Text, nullable=True)
# Alert conditions and triggers
trigger_condition = Column(String(200), nullable=True)
threshold_value = Column(Numeric(15, 4), nullable=True)
actual_value = Column(Numeric(15, 4), nullable=True)
variance = Column(Numeric(15, 4), nullable=True)
# Context data
alert_data = Column(JSONB, nullable=True) # Additional context-specific data
business_impact = Column(Text, nullable=True)
customer_impact = Column(Text, nullable=True)
financial_impact = Column(Numeric(12, 2), nullable=True)
# Alert status and lifecycle
status = Column(String(50), nullable=False, default="active", index=True)
# Status values: active, acknowledged, in_progress, resolved, dismissed, expired
alert_state = Column(String(50), nullable=False, default="new") # new, escalated, recurring
# Resolution and follow-up
resolution_action = Column(String(200), nullable=True)
resolution_notes = Column(Text, nullable=True)
resolution_cost = Column(Numeric(10, 2), nullable=True)
# Timing and escalation
first_occurred_at = Column(DateTime(timezone=True), nullable=False, index=True)
last_occurred_at = Column(DateTime(timezone=True), nullable=False)
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
resolved_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
# Occurrence tracking
occurrence_count = Column(Integer, nullable=False, default=1)
is_recurring = Column(Boolean, nullable=False, default=False)
recurrence_pattern = Column(String(100), nullable=True)
# Responsibility and assignment
assigned_to = Column(UUID(as_uuid=True), nullable=True)
assigned_role = Column(String(50), nullable=True) # orders_manager, procurement_manager, etc.
escalated_to = Column(UUID(as_uuid=True), nullable=True)
escalation_level = Column(Integer, nullable=False, default=0)
# Notification tracking
notification_sent = Column(Boolean, nullable=False, default=False)
notification_methods = Column(JSONB, nullable=True) # [email, sms, whatsapp, dashboard]
notification_recipients = Column(JSONB, nullable=True) # List of recipients
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
# Customer communication
customer_notified = Column(Boolean, nullable=False, default=False)
customer_notification_method = Column(String(50), nullable=True)
customer_message = Column(Text, nullable=True)
# Recommended actions
recommended_actions = Column(JSONB, nullable=True) # List of suggested actions
automated_actions_taken = Column(JSONB, nullable=True) # Actions performed automatically
manual_actions_required = Column(JSONB, nullable=True) # Actions requiring human intervention
# Priority and urgency
priority_score = Column(Integer, nullable=False, default=50) # 1-100 scale
urgency = Column(String(20), nullable=False, default="normal") # immediate, urgent, normal, low
business_priority = Column(String(20), nullable=False, default="normal")
# Related entities
related_orders = Column(JSONB, nullable=True) # Related order IDs
related_customers = Column(JSONB, nullable=True) # Related customer IDs
related_suppliers = Column(JSONB, nullable=True) # Related supplier IDs
related_alerts = Column(JSONB, nullable=True) # Related alert IDs
# Performance tracking
detection_time = Column(DateTime(timezone=True), nullable=True) # When issue was detected
response_time_minutes = Column(Integer, nullable=True) # Time to acknowledge
resolution_time_minutes = Column(Integer, nullable=True) # Time to resolve
# Quality and feedback
alert_accuracy = Column(Boolean, nullable=True) # Was this a valid alert?
false_positive = Column(Boolean, nullable=False, default=False)
feedback_notes = Column(Text, nullable=True)
# Compliance and audit
compliance_related = Column(Boolean, nullable=False, default=False)
audit_trail = Column(JSONB, nullable=True) # Changes and actions taken
regulatory_impact = Column(String(200), nullable=True)
# Integration and external systems
external_system_reference = Column(String(100), nullable=True)
external_ticket_number = Column(String(50), nullable=True)
erp_reference = Column(String(100), nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), nullable=True)
updated_by = Column(UUID(as_uuid=True), nullable=True)
# Additional metadata
alert_metadata = Column(JSONB, nullable=True)

View File

@@ -17,7 +17,6 @@ from shared.clients import (
ProductionServiceClient,
SalesServiceClient
)
from shared.notifications.alert_integration import AlertIntegration
from shared.database.transactions import transactional
from app.core.config import settings
@@ -52,7 +51,6 @@ class OrdersService:
inventory_client: InventoryServiceClient,
production_client: ProductionServiceClient,
sales_client: SalesServiceClient,
alert_integration: AlertIntegration
):
self.order_repo = order_repo
self.customer_repo = customer_repo
@@ -61,7 +59,6 @@ class OrdersService:
self.inventory_client = inventory_client
self.production_client = production_client
self.sales_client = sales_client
self.alert_integration = alert_integration
@transactional
async def create_order(
@@ -137,8 +134,6 @@ class OrdersService:
if business_model:
order.business_model = business_model
# 9. Check for high-value or rush orders for alerts
await self._check_order_alerts(db, order, order_data.tenant_id)
# 10. Integrate with production service if auto-processing is enabled
if settings.ORDER_PROCESSING_ENABLED:
@@ -440,46 +435,6 @@ class OrdersService:
# Fallback to UUID
return f"ORD-{uuid.uuid4().hex[:8].upper()}"
async def _check_order_alerts(self, db, order, tenant_id: UUID):
"""Check for conditions that require alerts"""
try:
alerts = []
# High-value order alert
if order.total_amount > settings.HIGH_VALUE_ORDER_THRESHOLD:
alerts.append({
"type": "high_value_order",
"severity": "medium",
"message": f"High-value order created: ${order.total_amount}"
})
# Rush order alert
if order.order_type == "rush":
time_to_delivery = order.requested_delivery_date - order.order_date
if time_to_delivery.total_seconds() < settings.RUSH_ORDER_HOURS_THRESHOLD * 3600:
alerts.append({
"type": "rush_order",
"severity": "high",
"message": f"Rush order with tight deadline: {order.order_number}"
})
# Large quantity alert
total_items = sum(item.quantity for item in order.items)
if total_items > settings.LARGE_QUANTITY_ORDER_THRESHOLD:
alerts.append({
"type": "large_quantity_order",
"severity": "medium",
"message": f"Large quantity order: {total_items} items"
})
# Send alerts if any
for alert in alerts:
await self._send_alert(tenant_id, order.id, alert)
except Exception as e:
logger.error("Error checking order alerts",
order_id=str(order.id),
error=str(e))
async def _notify_production_service(self, order):
"""Notify production service of new order"""
@@ -526,21 +481,3 @@ class OrdersService:
order_id=str(order.id),
error=str(e))
async def _send_alert(self, tenant_id: UUID, order_id: UUID, alert: Dict[str, Any]):
"""Send alert notification"""
try:
if self.notification_client:
await self.notification_client.send_alert(
str(tenant_id),
{
"alert_type": alert["type"],
"severity": alert["severity"],
"message": alert["message"],
"source_entity_id": str(order_id),
"source_entity_type": "order"
}
)
except Exception as e:
logger.warning("Failed to send alert",
tenant_id=str(tenant_id),
error=str(e))

View File

@@ -14,12 +14,10 @@ import structlog
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
from app.core.database import get_db
from app.services.production_service import ProductionService
from app.services.production_alert_service import ProductionAlertService
from app.schemas.production import (
ProductionBatchCreate, ProductionBatchUpdate, ProductionBatchStatusUpdate,
ProductionBatchResponse, ProductionBatchListResponse,
DailyProductionRequirements, ProductionDashboardSummary, ProductionMetrics,
ProductionAlertResponse, ProductionAlertListResponse
)
from app.core.config import settings
@@ -34,10 +32,6 @@ def get_production_service() -> ProductionService:
return ProductionService(database_manager, settings)
def get_production_alert_service() -> ProductionAlertService:
"""Dependency injection for production alert service"""
from app.core.database import database_manager
return ProductionAlertService(database_manager, settings)
# ================================================================
@@ -319,74 +313,6 @@ async def get_production_schedule(
raise HTTPException(status_code=500, detail="Failed to get production schedule")
# ================================================================
# ALERTS ENDPOINTS
# ================================================================
@router.get("/tenants/{tenant_id}/production/alerts", response_model=ProductionAlertListResponse)
async def get_production_alerts(
tenant_id: UUID = Path(...),
active_only: bool = Query(True, description="Return only active alerts"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
alert_service: ProductionAlertService = Depends(get_production_alert_service)
):
"""Get production-related alerts"""
try:
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
if active_only:
alerts = await alert_service.get_active_alerts(tenant_id)
else:
# Get all alerts (would need additional repo method)
alerts = await alert_service.get_active_alerts(tenant_id)
alert_responses = [ProductionAlertResponse.model_validate(alert) for alert in alerts]
logger.info("Retrieved production alerts",
count=len(alerts), tenant_id=str(tenant_id))
return ProductionAlertListResponse(
alerts=alert_responses,
total_count=len(alerts),
page=1,
page_size=len(alerts)
)
except Exception as e:
logger.error("Error getting production alerts",
error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to get production alerts")
@router.post("/tenants/{tenant_id}/production/alerts/{alert_id}/acknowledge", response_model=ProductionAlertResponse)
async def acknowledge_alert(
tenant_id: UUID = Path(...),
alert_id: UUID = Path(...),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
alert_service: ProductionAlertService = Depends(get_production_alert_service)
):
"""Acknowledge a production-related alert"""
try:
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
acknowledged_by = current_user.get("email", "unknown_user")
alert = await alert_service.acknowledge_alert(tenant_id, alert_id, acknowledged_by)
logger.info("Acknowledged production alert",
alert_id=str(alert_id),
acknowledged_by=acknowledged_by,
tenant_id=str(tenant_id))
return ProductionAlertResponse.model_validate(alert)
except Exception as e:
logger.error("Error acknowledging production alert",
error=str(e), alert_id=str(alert_id), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to acknowledge alert")
# ================================================================

View File

@@ -73,11 +73,6 @@ class ProductionSettings(BaseServiceSettings):
HOLIDAY_PRODUCTION_FACTOR: float = float(os.getenv("HOLIDAY_PRODUCTION_FACTOR", "0.3"))
SPECIAL_EVENT_PRODUCTION_FACTOR: float = float(os.getenv("SPECIAL_EVENT_PRODUCTION_FACTOR", "1.5"))
# Alert Thresholds
CAPACITY_EXCEEDED_THRESHOLD: float = float(os.getenv("CAPACITY_EXCEEDED_THRESHOLD", "1.0"))
PRODUCTION_DELAY_THRESHOLD_MINUTES: int = int(os.getenv("PRODUCTION_DELAY_THRESHOLD_MINUTES", "60"))
LOW_YIELD_ALERT_THRESHOLD: float = float(os.getenv("LOW_YIELD_ALERT_THRESHOLD", "0.80"))
URGENT_ORDER_THRESHOLD_HOURS: int = int(os.getenv("URGENT_ORDER_THRESHOLD_HOURS", "4"))
# Cost Management
COST_TRACKING_ENABLED: bool = os.getenv("COST_TRACKING_ENABLED", "true").lower() == "true"

View File

@@ -9,14 +9,12 @@ from .production import (
ProductionBatch,
ProductionSchedule,
ProductionCapacity,
QualityCheck,
ProductionAlert
QualityCheck
)
__all__ = [
"ProductionBatch",
"ProductionSchedule",
"ProductionCapacity",
"QualityCheck",
"ProductionAlert"
"QualityCheck"
]

View File

@@ -35,12 +35,6 @@ class ProductionPriority(str, enum.Enum):
URGENT = "urgent"
class AlertSeverity(str, enum.Enum):
"""Alert severity levels"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class ProductionBatch(Base):
@@ -391,81 +385,3 @@ class QualityCheck(Base):
}
class ProductionAlert(Base):
"""Production alert model for tracking production issues and notifications"""
__tablename__ = "production_alerts"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Alert classification
alert_type = Column(String(50), nullable=False, index=True) # capacity_exceeded, delay, quality_issue, etc.
severity = Column(SQLEnum(AlertSeverity), nullable=False, default=AlertSeverity.MEDIUM)
title = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
# Context
batch_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Associated batch if applicable
schedule_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Associated schedule if applicable
source_system = Column(String(50), nullable=False, default="production")
# Status
is_active = Column(Boolean, default=True)
is_acknowledged = Column(Boolean, default=False)
is_resolved = Column(Boolean, default=False)
# Actions and recommendations
recommended_actions = Column(JSON, nullable=True) # List of suggested actions
actions_taken = Column(JSON, nullable=True) # List of actions actually taken
# Business impact
impact_level = Column(String(20), nullable=True) # low, medium, high, critical
estimated_cost_impact = Column(Float, nullable=True)
estimated_time_impact_minutes = Column(Integer, nullable=True)
# Resolution tracking
acknowledged_by = Column(String(100), nullable=True)
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
resolved_by = Column(String(100), nullable=True)
resolved_at = Column(DateTime(timezone=True), nullable=True)
resolution_notes = Column(Text, nullable=True)
# Alert data
alert_data = Column(JSON, nullable=True) # Additional context data
alert_metadata = Column(JSON, nullable=True) # Metadata for the alert
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary following shared pattern"""
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"alert_type": self.alert_type,
"severity": self.severity.value if self.severity else None,
"title": self.title,
"message": self.message,
"batch_id": str(self.batch_id) if self.batch_id else None,
"schedule_id": str(self.schedule_id) if self.schedule_id else None,
"source_system": self.source_system,
"is_active": self.is_active,
"is_acknowledged": self.is_acknowledged,
"is_resolved": self.is_resolved,
"recommended_actions": self.recommended_actions,
"actions_taken": self.actions_taken,
"impact_level": self.impact_level,
"estimated_cost_impact": self.estimated_cost_impact,
"estimated_time_impact_minutes": self.estimated_time_impact_minutes,
"acknowledged_by": self.acknowledged_by,
"acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None,
"resolved_by": self.resolved_by,
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
"resolution_notes": self.resolution_notes,
"alert_data": self.alert_data,
"alert_metadata": self.alert_metadata,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -9,12 +9,10 @@ from .production_batch_repository import ProductionBatchRepository
from .production_schedule_repository import ProductionScheduleRepository
from .production_capacity_repository import ProductionCapacityRepository
from .quality_check_repository import QualityCheckRepository
from .production_alert_repository import ProductionAlertRepository
__all__ = [
"ProductionBatchRepository",
"ProductionScheduleRepository",
"ProductionCapacityRepository",
"QualityCheckRepository",
"ProductionAlertRepository"
]

View File

@@ -1,379 +0,0 @@
"""
Production Alert Repository
Repository for production alert operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, text, desc, func
from datetime import datetime, timedelta, date
from uuid import UUID
import structlog
from .base import ProductionBaseRepository
from app.models.production import ProductionAlert, AlertSeverity
from shared.database.exceptions import DatabaseError, ValidationError
from shared.database.transactions import transactional
logger = structlog.get_logger()
class ProductionAlertRepository(ProductionBaseRepository):
"""Repository for production alert operations"""
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 60):
# Alerts are very dynamic, very short cache time (1 minute)
super().__init__(ProductionAlert, session, cache_ttl)
@transactional
async def create_alert(self, alert_data: Dict[str, Any]) -> ProductionAlert:
"""Create a new production alert with validation"""
try:
# Validate alert data
validation_result = self._validate_production_data(
alert_data,
["tenant_id", "alert_type", "title", "message"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid alert data: {validation_result['errors']}")
# Set default values
if "severity" not in alert_data:
alert_data["severity"] = AlertSeverity.MEDIUM
if "source_system" not in alert_data:
alert_data["source_system"] = "production"
if "is_active" not in alert_data:
alert_data["is_active"] = True
if "is_acknowledged" not in alert_data:
alert_data["is_acknowledged"] = False
if "is_resolved" not in alert_data:
alert_data["is_resolved"] = False
# Create alert
alert = await self.create(alert_data)
logger.info("Production alert created successfully",
alert_id=str(alert.id),
alert_type=alert.alert_type,
severity=alert.severity.value if alert.severity else None,
tenant_id=str(alert.tenant_id))
return alert
except ValidationError:
raise
except Exception as e:
logger.error("Error creating production alert", error=str(e))
raise DatabaseError(f"Failed to create production alert: {str(e)}")
@transactional
async def get_active_alerts(
self,
tenant_id: str,
severity: Optional[AlertSeverity] = None
) -> List[ProductionAlert]:
"""Get active production alerts for a tenant"""
try:
filters = {
"tenant_id": tenant_id,
"is_active": True,
"is_resolved": False
}
if severity:
filters["severity"] = severity
alerts = await self.get_multi(
filters=filters,
order_by="created_at",
order_desc=True
)
logger.info("Retrieved active production alerts",
count=len(alerts),
severity=severity.value if severity else "all",
tenant_id=tenant_id)
return alerts
except Exception as e:
logger.error("Error fetching active alerts", error=str(e))
raise DatabaseError(f"Failed to fetch active alerts: {str(e)}")
@transactional
async def get_alerts_by_type(
self,
tenant_id: str,
alert_type: str,
include_resolved: bool = False
) -> List[ProductionAlert]:
"""Get production alerts by type"""
try:
filters = {
"tenant_id": tenant_id,
"alert_type": alert_type
}
if not include_resolved:
filters["is_resolved"] = False
alerts = await self.get_multi(
filters=filters,
order_by="created_at",
order_desc=True
)
logger.info("Retrieved alerts by type",
count=len(alerts),
alert_type=alert_type,
include_resolved=include_resolved,
tenant_id=tenant_id)
return alerts
except Exception as e:
logger.error("Error fetching alerts by type", error=str(e))
raise DatabaseError(f"Failed to fetch alerts by type: {str(e)}")
@transactional
async def get_alerts_by_batch(
self,
tenant_id: str,
batch_id: str
) -> List[ProductionAlert]:
"""Get production alerts for a specific batch"""
try:
alerts = await self.get_multi(
filters={
"tenant_id": tenant_id,
"batch_id": batch_id
},
order_by="created_at",
order_desc=True
)
logger.info("Retrieved alerts by batch",
count=len(alerts),
batch_id=batch_id,
tenant_id=tenant_id)
return alerts
except Exception as e:
logger.error("Error fetching alerts by batch", error=str(e))
raise DatabaseError(f"Failed to fetch alerts by batch: {str(e)}")
@transactional
async def acknowledge_alert(
self,
alert_id: UUID,
acknowledged_by: str,
acknowledgment_notes: Optional[str] = None
) -> ProductionAlert:
"""Acknowledge a production alert"""
try:
alert = await self.get(alert_id)
if not alert:
raise ValidationError(f"Alert {alert_id} not found")
if alert.is_acknowledged:
raise ValidationError("Alert is already acknowledged")
update_data = {
"is_acknowledged": True,
"acknowledged_by": acknowledged_by,
"acknowledged_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
if acknowledgment_notes:
current_actions = alert.actions_taken or []
current_actions.append({
"action": "acknowledged",
"by": acknowledged_by,
"at": datetime.utcnow().isoformat(),
"notes": acknowledgment_notes
})
update_data["actions_taken"] = current_actions
alert = await self.update(alert_id, update_data)
logger.info("Acknowledged production alert",
alert_id=str(alert_id),
acknowledged_by=acknowledged_by)
return alert
except ValidationError:
raise
except Exception as e:
logger.error("Error acknowledging alert", error=str(e))
raise DatabaseError(f"Failed to acknowledge alert: {str(e)}")
@transactional
async def resolve_alert(
self,
alert_id: UUID,
resolved_by: str,
resolution_notes: str
) -> ProductionAlert:
"""Resolve a production alert"""
try:
alert = await self.get(alert_id)
if not alert:
raise ValidationError(f"Alert {alert_id} not found")
if alert.is_resolved:
raise ValidationError("Alert is already resolved")
update_data = {
"is_resolved": True,
"is_active": False,
"resolved_by": resolved_by,
"resolved_at": datetime.utcnow(),
"resolution_notes": resolution_notes,
"updated_at": datetime.utcnow()
}
# Add to actions taken
current_actions = alert.actions_taken or []
current_actions.append({
"action": "resolved",
"by": resolved_by,
"at": datetime.utcnow().isoformat(),
"notes": resolution_notes
})
update_data["actions_taken"] = current_actions
alert = await self.update(alert_id, update_data)
logger.info("Resolved production alert",
alert_id=str(alert_id),
resolved_by=resolved_by)
return alert
except ValidationError:
raise
except Exception as e:
logger.error("Error resolving alert", error=str(e))
raise DatabaseError(f"Failed to resolve alert: {str(e)}")
@transactional
async def get_alert_statistics(
self,
tenant_id: str,
start_date: date,
end_date: date
) -> Dict[str, Any]:
"""Get alert statistics for a tenant and date range"""
try:
start_datetime = datetime.combine(start_date, datetime.min.time())
end_datetime = datetime.combine(end_date, datetime.max.time())
alerts = await self.get_multi(
filters={
"tenant_id": tenant_id,
"created_at__gte": start_datetime,
"created_at__lte": end_datetime
}
)
total_alerts = len(alerts)
active_alerts = len([a for a in alerts if a.is_active])
acknowledged_alerts = len([a for a in alerts if a.is_acknowledged])
resolved_alerts = len([a for a in alerts if a.is_resolved])
# Group by severity
by_severity = {}
for severity in AlertSeverity:
severity_alerts = [a for a in alerts if a.severity == severity]
by_severity[severity.value] = {
"total": len(severity_alerts),
"active": len([a for a in severity_alerts if a.is_active]),
"resolved": len([a for a in severity_alerts if a.is_resolved])
}
# Group by alert type
by_type = {}
for alert in alerts:
alert_type = alert.alert_type
if alert_type not in by_type:
by_type[alert_type] = {
"total": 0,
"active": 0,
"resolved": 0
}
by_type[alert_type]["total"] += 1
if alert.is_active:
by_type[alert_type]["active"] += 1
if alert.is_resolved:
by_type[alert_type]["resolved"] += 1
# Calculate resolution time statistics
resolved_with_times = [
a for a in alerts
if a.is_resolved and a.resolved_at and a.created_at
]
resolution_times = []
for alert in resolved_with_times:
resolution_time = (alert.resolved_at - alert.created_at).total_seconds() / 3600 # hours
resolution_times.append(resolution_time)
avg_resolution_time = sum(resolution_times) / len(resolution_times) if resolution_times else 0
return {
"period_start": start_date.isoformat(),
"period_end": end_date.isoformat(),
"total_alerts": total_alerts,
"active_alerts": active_alerts,
"acknowledged_alerts": acknowledged_alerts,
"resolved_alerts": resolved_alerts,
"acknowledgment_rate": round((acknowledged_alerts / total_alerts * 100) if total_alerts > 0 else 0, 2),
"resolution_rate": round((resolved_alerts / total_alerts * 100) if total_alerts > 0 else 0, 2),
"average_resolution_time_hours": round(avg_resolution_time, 2),
"by_severity": by_severity,
"by_alert_type": by_type,
"tenant_id": tenant_id
}
except Exception as e:
logger.error("Error calculating alert statistics", error=str(e))
raise DatabaseError(f"Failed to calculate alert statistics: {str(e)}")
@transactional
async def cleanup_old_resolved_alerts(
self,
tenant_id: str,
days_to_keep: int = 30
) -> int:
"""Clean up old resolved alerts"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep)
old_alerts = await self.get_multi(
filters={
"tenant_id": tenant_id,
"is_resolved": True,
"resolved_at__lt": cutoff_date
}
)
deleted_count = 0
for alert in old_alerts:
await self.delete(alert.id)
deleted_count += 1
logger.info("Cleaned up old resolved alerts",
deleted_count=deleted_count,
days_to_keep=days_to_keep,
tenant_id=tenant_id)
return deleted_count
except Exception as e:
logger.error("Error cleaning up old alerts", error=str(e))
raise DatabaseError(f"Failed to clean up old alerts: {str(e)}")

View File

@@ -31,12 +31,6 @@ class ProductionPriorityEnum(str, Enum):
URGENT = "urgent"
class AlertSeverityEnum(str, Enum):
"""Alert severity levels for API"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
# ================================================================
@@ -280,61 +274,6 @@ class QualityCheckResponse(BaseModel):
from_attributes = True
# ================================================================
# PRODUCTION ALERT SCHEMAS
# ================================================================
class ProductionAlertBase(BaseModel):
"""Base schema for production alert"""
alert_type: str = Field(..., min_length=1, max_length=50)
severity: AlertSeverityEnum = AlertSeverityEnum.MEDIUM
title: str = Field(..., min_length=1, max_length=255)
message: str = Field(..., min_length=1)
batch_id: Optional[UUID] = None
schedule_id: Optional[UUID] = None
class ProductionAlertCreate(ProductionAlertBase):
"""Schema for creating a production alert"""
recommended_actions: Optional[List[str]] = None
impact_level: Optional[str] = Field(None, pattern="^(low|medium|high|critical)$")
estimated_cost_impact: Optional[float] = Field(None, ge=0)
estimated_time_impact_minutes: Optional[int] = Field(None, ge=0)
alert_data: Optional[Dict[str, Any]] = None
alert_metadata: Optional[Dict[str, Any]] = None
class ProductionAlertResponse(BaseModel):
"""Schema for production alert response"""
id: UUID
tenant_id: UUID
alert_type: str
severity: AlertSeverityEnum
title: str
message: str
batch_id: Optional[UUID]
schedule_id: Optional[UUID]
source_system: str
is_active: bool
is_acknowledged: bool
is_resolved: bool
recommended_actions: Optional[List[str]]
actions_taken: Optional[List[Dict[str, Any]]]
impact_level: Optional[str]
estimated_cost_impact: Optional[float]
estimated_time_impact_minutes: Optional[int]
acknowledged_by: Optional[str]
acknowledged_at: Optional[datetime]
resolved_by: Optional[str]
resolved_at: Optional[datetime]
resolution_notes: Optional[str]
alert_data: Optional[Dict[str, Any]]
alert_metadata: Optional[Dict[str, Any]]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# ================================================================
@@ -346,7 +285,6 @@ class ProductionDashboardSummary(BaseModel):
active_batches: int
todays_production_plan: List[Dict[str, Any]]
capacity_utilization: float
current_alerts: int
on_time_completion_rate: float
average_quality_score: float
total_output_today: float
@@ -406,9 +344,3 @@ class QualityCheckListResponse(BaseModel):
page_size: int
class ProductionAlertListResponse(BaseModel):
"""Schema for production alert list response"""
alerts: List[ProductionAlertResponse]
total_count: int
page: int
page_size: int

View File

@@ -6,9 +6,7 @@ Business logic services
"""
from .production_service import ProductionService
from .production_alert_service import ProductionAlertService
__all__ = [
"ProductionService",
"ProductionAlertService"
"ProductionService"
]

View File

@@ -1,435 +0,0 @@
"""
Production Alert Service
Business logic for production alerts and notifications
"""
from typing import Optional, List, Dict, Any
from datetime import datetime, date, timedelta
from uuid import UUID
import structlog
from shared.database.transactions import transactional
from shared.notifications.alert_integration import AlertIntegration
from shared.config.base import BaseServiceSettings
from app.repositories.production_alert_repository import ProductionAlertRepository
from app.repositories.production_batch_repository import ProductionBatchRepository
from app.repositories.production_capacity_repository import ProductionCapacityRepository
from app.models.production import ProductionAlert, AlertSeverity, ProductionStatus
from app.schemas.production import ProductionAlertCreate
logger = structlog.get_logger()
class ProductionAlertService:
"""Production alert service with comprehensive monitoring"""
def __init__(self, database_manager, config: BaseServiceSettings):
self.database_manager = database_manager
self.config = config
self.alert_integration = AlertIntegration()
@transactional
async def check_production_capacity_alerts(self, tenant_id: UUID) -> List[ProductionAlert]:
"""Monitor production capacity and generate alerts"""
alerts = []
try:
async with self.database_manager.get_session() as session:
batch_repo = ProductionBatchRepository(session)
capacity_repo = ProductionCapacityRepository(session)
alert_repo = ProductionAlertRepository(session)
today = date.today()
# Check capacity exceeded alert
todays_batches = await batch_repo.get_batches_by_date_range(
str(tenant_id), today, today
)
# Calculate total planned hours for today
total_planned_hours = sum(
batch.planned_duration_minutes / 60
for batch in todays_batches
if batch.status != ProductionStatus.CANCELLED
)
# Get available capacity
available_capacity = await capacity_repo.get_capacity_utilization_summary(
str(tenant_id), today, today
)
total_capacity = available_capacity.get("total_capacity_units", 8.0)
if total_planned_hours > total_capacity:
excess_hours = total_planned_hours - total_capacity
alert_data = ProductionAlertCreate(
alert_type="production_capacity_exceeded",
severity=AlertSeverity.HIGH,
title="Capacidad de Producción Excedida",
message=f"🔥 Capacidad excedida: {excess_hours:.1f}h extra necesarias para completar la producción de hoy",
recommended_actions=[
"reschedule_batches",
"outsource_production",
"adjust_menu",
"extend_working_hours"
],
impact_level="high",
estimated_time_impact_minutes=int(excess_hours * 60),
alert_data={
"excess_hours": excess_hours,
"total_planned_hours": total_planned_hours,
"available_capacity_hours": total_capacity,
"affected_batches": len(todays_batches)
}
)
alert = await alert_repo.create_alert({
**alert_data.model_dump(),
"tenant_id": tenant_id
})
alerts.append(alert)
# Check production delay alert
current_time = datetime.utcnow()
cutoff_time = current_time + timedelta(hours=4) # 4 hours ahead
urgent_batches = await batch_repo.get_urgent_batches(str(tenant_id), 4)
delayed_batches = [
batch for batch in urgent_batches
if batch.planned_start_time <= current_time and batch.status == ProductionStatus.PENDING
]
for batch in delayed_batches:
delay_minutes = int((current_time - batch.planned_start_time).total_seconds() / 60)
if delay_minutes > self.config.PRODUCTION_DELAY_THRESHOLD_MINUTES:
alert_data = ProductionAlertCreate(
alert_type="production_delay",
severity=AlertSeverity.HIGH,
title="Retraso en Producción",
message=f"⏰ Retraso: {batch.product_name} debía haber comenzado hace {delay_minutes} minutos",
batch_id=batch.id,
recommended_actions=[
"start_production_immediately",
"notify_staff",
"prepare_alternatives",
"update_customers"
],
impact_level="high",
estimated_time_impact_minutes=delay_minutes,
alert_data={
"batch_number": batch.batch_number,
"product_name": batch.product_name,
"planned_start_time": batch.planned_start_time.isoformat(),
"delay_minutes": delay_minutes,
"affects_opening": delay_minutes > 120 # 2 hours
}
)
alert = await alert_repo.create_alert({
**alert_data.model_dump(),
"tenant_id": tenant_id
})
alerts.append(alert)
# Check cost spike alert
high_cost_batches = [
batch for batch in todays_batches
if batch.estimated_cost and batch.estimated_cost > 100 # Threshold
]
if high_cost_batches:
total_high_cost = sum(batch.estimated_cost for batch in high_cost_batches)
alert_data = ProductionAlertCreate(
alert_type="production_cost_spike",
severity=AlertSeverity.MEDIUM,
title="Costos de Producción Elevados",
message=f"💰 Costos altos detectados: {len(high_cost_batches)} lotes con costo total de {total_high_cost:.2f}",
recommended_actions=[
"review_ingredient_costs",
"optimize_recipe",
"negotiate_supplier_prices",
"adjust_menu_pricing"
],
impact_level="medium",
estimated_cost_impact=total_high_cost,
alert_data={
"high_cost_batches": len(high_cost_batches),
"total_cost": total_high_cost,
"average_cost": total_high_cost / len(high_cost_batches),
"affected_products": [batch.product_name for batch in high_cost_batches]
}
)
alert = await alert_repo.create_alert({
**alert_data.model_dump(),
"tenant_id": tenant_id
})
alerts.append(alert)
# Send alerts using notification service
await self._send_alerts(tenant_id, alerts)
return alerts
except Exception as e:
logger.error("Error checking production capacity alerts",
error=str(e), tenant_id=str(tenant_id))
return []
@transactional
async def check_quality_control_alerts(self, tenant_id: UUID) -> List[ProductionAlert]:
"""Monitor quality control issues and generate alerts"""
alerts = []
try:
async with self.database_manager.get_session() as session:
alert_repo = ProductionAlertRepository(session)
batch_repo = ProductionBatchRepository(session)
# Check for batches with low yield
last_week = date.today() - timedelta(days=7)
recent_batches = await batch_repo.get_batches_by_date_range(
str(tenant_id), last_week, date.today(), ProductionStatus.COMPLETED
)
low_yield_batches = [
batch for batch in recent_batches
if batch.yield_percentage and batch.yield_percentage < self.config.LOW_YIELD_ALERT_THRESHOLD * 100
]
if low_yield_batches:
avg_yield = sum(batch.yield_percentage for batch in low_yield_batches) / len(low_yield_batches)
alert_data = ProductionAlertCreate(
alert_type="low_yield_detected",
severity=AlertSeverity.MEDIUM,
title="Rendimiento Bajo Detectado",
message=f"📉 Rendimiento bajo: {len(low_yield_batches)} lotes con rendimiento promedio {avg_yield:.1f}%",
recommended_actions=[
"review_recipes",
"check_ingredient_quality",
"training_staff",
"equipment_calibration"
],
impact_level="medium",
alert_data={
"low_yield_batches": len(low_yield_batches),
"average_yield": avg_yield,
"threshold": self.config.LOW_YIELD_ALERT_THRESHOLD * 100,
"affected_products": list(set(batch.product_name for batch in low_yield_batches))
}
)
alert = await alert_repo.create_alert({
**alert_data.model_dump(),
"tenant_id": tenant_id
})
alerts.append(alert)
# Check for recurring quality issues
quality_issues = [
batch for batch in recent_batches
if batch.quality_score and batch.quality_score < self.config.QUALITY_SCORE_THRESHOLD
]
if len(quality_issues) >= 3: # 3 or more quality issues in a week
avg_quality = sum(batch.quality_score for batch in quality_issues) / len(quality_issues)
alert_data = ProductionAlertCreate(
alert_type="recurring_quality_issues",
severity=AlertSeverity.HIGH,
title="Problemas de Calidad Recurrentes",
message=f"⚠️ Problemas de calidad: {len(quality_issues)} lotes con calidad promedio {avg_quality:.1f}/10",
recommended_actions=[
"quality_audit",
"staff_retraining",
"equipment_maintenance",
"supplier_review"
],
impact_level="high",
alert_data={
"quality_issues_count": len(quality_issues),
"average_quality_score": avg_quality,
"threshold": self.config.QUALITY_SCORE_THRESHOLD,
"trend": "declining"
}
)
alert = await alert_repo.create_alert({
**alert_data.model_dump(),
"tenant_id": tenant_id
})
alerts.append(alert)
# Send alerts
await self._send_alerts(tenant_id, alerts)
return alerts
except Exception as e:
logger.error("Error checking quality control alerts",
error=str(e), tenant_id=str(tenant_id))
return []
@transactional
async def check_equipment_maintenance_alerts(self, tenant_id: UUID) -> List[ProductionAlert]:
"""Monitor equipment status and generate maintenance alerts"""
alerts = []
try:
async with self.database_manager.get_session() as session:
capacity_repo = ProductionCapacityRepository(session)
alert_repo = ProductionAlertRepository(session)
# Get equipment that needs maintenance
today = date.today()
equipment_capacity = await capacity_repo.get_multi(
filters={
"tenant_id": str(tenant_id),
"resource_type": "equipment",
"date": today
}
)
for equipment in equipment_capacity:
# Check if maintenance is overdue
if equipment.last_maintenance_date:
days_since_maintenance = (today - equipment.last_maintenance_date.date()).days
if days_since_maintenance > 30: # 30 days threshold
alert_data = ProductionAlertCreate(
alert_type="equipment_maintenance_overdue",
severity=AlertSeverity.MEDIUM,
title="Mantenimiento de Equipo Vencido",
message=f"🔧 Mantenimiento vencido: {equipment.resource_name} - {days_since_maintenance} días sin mantenimiento",
recommended_actions=[
"schedule_maintenance",
"equipment_inspection",
"backup_equipment_ready"
],
impact_level="medium",
alert_data={
"equipment_id": equipment.resource_id,
"equipment_name": equipment.resource_name,
"days_since_maintenance": days_since_maintenance,
"last_maintenance": equipment.last_maintenance_date.isoformat() if equipment.last_maintenance_date else None
}
)
alert = await alert_repo.create_alert({
**alert_data.model_dump(),
"tenant_id": tenant_id
})
alerts.append(alert)
# Check equipment efficiency
if equipment.efficiency_rating and equipment.efficiency_rating < 0.8: # 80% threshold
alert_data = ProductionAlertCreate(
alert_type="equipment_efficiency_low",
severity=AlertSeverity.MEDIUM,
title="Eficiencia de Equipo Baja",
message=f"📊 Eficiencia baja: {equipment.resource_name} operando al {equipment.efficiency_rating*100:.1f}%",
recommended_actions=[
"equipment_calibration",
"maintenance_check",
"replace_parts"
],
impact_level="medium",
alert_data={
"equipment_id": equipment.resource_id,
"equipment_name": equipment.resource_name,
"efficiency_rating": equipment.efficiency_rating,
"threshold": 0.8
}
)
alert = await alert_repo.create_alert({
**alert_data.model_dump(),
"tenant_id": tenant_id
})
alerts.append(alert)
# Send alerts
await self._send_alerts(tenant_id, alerts)
return alerts
except Exception as e:
logger.error("Error checking equipment maintenance alerts",
error=str(e), tenant_id=str(tenant_id))
return []
async def _send_alerts(self, tenant_id: UUID, alerts: List[ProductionAlert]):
"""Send alerts using notification service with proper urgency handling"""
try:
for alert in alerts:
# Determine delivery channels based on severity
channels = self._get_channels_by_severity(alert.severity)
# Send notification using alert integration
await self.alert_integration.send_alert(
tenant_id=str(tenant_id),
message=alert.message,
alert_type=alert.alert_type,
severity=alert.severity.value,
channels=channels,
data={
"actions": alert.recommended_actions or [],
"alert_id": str(alert.id)
}
)
logger.info("Sent production alert notification",
alert_id=str(alert.id),
alert_type=alert.alert_type,
severity=alert.severity.value,
channels=channels)
except Exception as e:
logger.error("Error sending alert notifications",
error=str(e), tenant_id=str(tenant_id))
def _get_channels_by_severity(self, severity: AlertSeverity) -> List[str]:
"""Map severity to delivery channels following user-centric analysis"""
if severity == AlertSeverity.CRITICAL:
return ["whatsapp", "email", "dashboard", "sms"]
elif severity == AlertSeverity.HIGH:
return ["whatsapp", "email", "dashboard"]
elif severity == AlertSeverity.MEDIUM:
return ["email", "dashboard"]
else:
return ["dashboard"]
@transactional
async def get_active_alerts(self, tenant_id: UUID) -> List[ProductionAlert]:
"""Get all active production alerts for a tenant"""
try:
async with self.database_manager.get_session() as session:
alert_repo = ProductionAlertRepository(session)
return await alert_repo.get_active_alerts(str(tenant_id))
except Exception as e:
logger.error("Error getting active alerts",
error=str(e), tenant_id=str(tenant_id))
return []
@transactional
async def acknowledge_alert(
self,
tenant_id: UUID,
alert_id: UUID,
acknowledged_by: str
) -> ProductionAlert:
"""Acknowledge a production alert"""
try:
async with self.database_manager.get_session() as session:
alert_repo = ProductionAlertRepository(session)
return await alert_repo.acknowledge_alert(alert_id, acknowledged_by)
except Exception as e:
logger.error("Error acknowledging alert",
error=str(e), alert_id=str(alert_id), tenant_id=str(tenant_id))
raise

View File

@@ -205,7 +205,6 @@ class ProductionService:
active_batches=len(active_batches),
todays_production_plan=todays_plan,
capacity_utilization=85.0, # TODO: Calculate from actual capacity data
current_alerts=0, # TODO: Get from alerts
on_time_completion_rate=weekly_metrics.get("on_time_completion_rate", 0),
average_quality_score=8.5, # TODO: Get from quality checks
total_output_today=sum(b.actual_quantity or 0 for b in todays_batches),

View File

@@ -5,18 +5,4 @@
Shared Notifications Module - Alert integration using existing notification service
"""
from .alert_integration import (
AlertIntegration,
AlertSeverity,
AlertType,
AlertCategory,
AlertSource
)
__all__ = [
'AlertIntegration',
'AlertSeverity',
'AlertType',
'AlertCategory',
'AlertSource'
]
__all__ = []

View File

@@ -1,285 +0,0 @@
# ================================================================
# shared/notifications/alert_integration.py
# ================================================================
"""
Simplified Alert Integration - Placeholder for unified alert system
"""
import structlog
from typing import Optional, Dict, Any, List
from datetime import datetime
import enum
from uuid import UUID
logger = structlog.get_logger()
class AlertSeverity(enum.Enum):
"""Alert severity levels"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class AlertType(enum.Enum):
"""Alert types for different bakery operations"""
# Production Alerts
PRODUCTION_DELAY = "production_delay"
BATCH_FAILURE = "batch_failure"
EQUIPMENT_MALFUNCTION = "equipment_malfunction"
TEMPERATURE_VIOLATION = "temperature_violation"
QUALITY_ISSUE = "quality_issue"
# Inventory Alerts
LOW_STOCK = "low_stock"
OUT_OF_STOCK = "out_of_stock"
EXPIRATION_WARNING = "expiration_warning"
TEMPERATURE_BREACH = "temperature_breach"
FOOD_SAFETY_VIOLATION = "food_safety_violation"
# Supplier Alerts
SUPPLIER_PERFORMANCE = "supplier_performance"
DELIVERY_DELAY = "delivery_delay"
QUALITY_ISSUES = "quality_issues"
CONTRACT_EXPIRY = "contract_expiry"
# Order Alerts
ORDER_DELAY = "order_delay"
CUSTOMER_COMPLAINT = "customer_complaint"
PAYMENT_ISSUE = "payment_issue"
class AlertSource(enum.Enum):
"""Sources that can generate alerts"""
PRODUCTION_SERVICE = "production_service"
INVENTORY_SERVICE = "inventory_service"
SUPPLIERS_SERVICE = "suppliers_service"
ORDERS_SERVICE = "orders_service"
EXTERNAL_SERVICE = "external_service"
class AlertCategory(enum.Enum):
"""Alert categories for organization"""
OPERATIONAL = "operational"
QUALITY = "quality"
SAFETY = "safety"
FINANCIAL = "financial"
COMPLIANCE = "compliance"
class AlertIntegration:
"""
Simplified alert integration that logs alerts.
TODO: Implement proper service-to-service communication for notifications
"""
def __init__(self):
self.logger = structlog.get_logger("alert_integration")
async def create_alert(
self,
tenant_id: UUID,
alert_type: AlertType,
severity: AlertSeverity,
title: str,
message: str,
source: AlertSource,
category: AlertCategory = None,
entity_id: Optional[UUID] = None,
metadata: Optional[Dict[str, Any]] = None,
recipients: Optional[List[UUID]] = None
) -> Optional[str]:
"""
Create a new alert (currently just logs it)
Returns:
Alert ID if successful, None otherwise
"""
try:
alert_data = {
"tenant_id": str(tenant_id),
"alert_type": alert_type.value,
"severity": severity.value,
"title": title,
"message": message,
"source": source.value,
"category": category.value if category else None,
"entity_id": str(entity_id) if entity_id else None,
"metadata": metadata or {},
"recipients": [str(r) for r in recipients] if recipients else [],
"timestamp": datetime.utcnow().isoformat()
}
# For now, just log the alert
self.logger.info(
"Alert created",
**alert_data
)
# Return a mock alert ID
return f"alert_{datetime.utcnow().timestamp()}"
except Exception as e:
self.logger.error(
"Failed to create alert",
tenant_id=str(tenant_id),
alert_type=alert_type.value,
error=str(e)
)
return None
async def acknowledge_alert(self, alert_id: str, user_id: UUID) -> bool:
"""Acknowledge an alert (currently just logs it)"""
try:
self.logger.info(
"Alert acknowledged",
alert_id=alert_id,
user_id=str(user_id),
timestamp=datetime.utcnow().isoformat()
)
return True
except Exception as e:
self.logger.error(
"Failed to acknowledge alert",
alert_id=alert_id,
error=str(e)
)
return False
async def resolve_alert(self, alert_id: str, user_id: UUID, resolution: str = None) -> bool:
"""Resolve an alert (currently just logs it)"""
try:
self.logger.info(
"Alert resolved",
alert_id=alert_id,
user_id=str(user_id),
resolution=resolution,
timestamp=datetime.utcnow().isoformat()
)
return True
except Exception as e:
self.logger.error(
"Failed to resolve alert",
alert_id=alert_id,
error=str(e)
)
return False
# Convenience methods for specific alert types
async def create_inventory_alert(
self,
tenant_id: UUID,
alert_type: AlertType,
severity: AlertSeverity,
title: str,
message: str,
item_id: UUID = None,
**kwargs
) -> Optional[str]:
"""Create an inventory-specific alert"""
metadata = kwargs.pop('metadata', {})
if item_id:
metadata['item_id'] = str(item_id)
return await self.create_alert(
tenant_id=tenant_id,
alert_type=alert_type,
severity=severity,
title=title,
message=message,
source=AlertSource.INVENTORY_SERVICE,
category=AlertCategory.OPERATIONAL,
entity_id=item_id,
metadata=metadata,
**kwargs
)
async def create_production_alert(
self,
tenant_id: UUID,
alert_type: AlertType,
severity: AlertSeverity,
title: str,
message: str,
batch_id: UUID = None,
equipment_id: UUID = None,
**kwargs
) -> Optional[str]:
"""Create a production-specific alert"""
metadata = kwargs.pop('metadata', {})
if batch_id:
metadata['batch_id'] = str(batch_id)
if equipment_id:
metadata['equipment_id'] = str(equipment_id)
return await self.create_alert(
tenant_id=tenant_id,
alert_type=alert_type,
severity=severity,
title=title,
message=message,
source=AlertSource.PRODUCTION_SERVICE,
category=AlertCategory.OPERATIONAL,
metadata=metadata,
**kwargs
)
async def create_supplier_alert(
self,
tenant_id: UUID,
alert_type: AlertType,
severity: AlertSeverity,
title: str,
message: str,
supplier_id: UUID = None,
**kwargs
) -> Optional[str]:
"""Create a supplier-specific alert"""
metadata = kwargs.pop('metadata', {})
if supplier_id:
metadata['supplier_id'] = str(supplier_id)
return await self.create_alert(
tenant_id=tenant_id,
alert_type=alert_type,
severity=severity,
title=title,
message=message,
source=AlertSource.SUPPLIERS_SERVICE,
category=AlertCategory.QUALITY,
entity_id=supplier_id,
metadata=metadata,
**kwargs
)
async def create_order_alert(
self,
tenant_id: UUID,
alert_type: AlertType,
severity: AlertSeverity,
title: str,
message: str,
order_id: UUID = None,
customer_id: UUID = None,
**kwargs
) -> Optional[str]:
"""Create an order-specific alert"""
metadata = kwargs.pop('metadata', {})
if order_id:
metadata['order_id'] = str(order_id)
if customer_id:
metadata['customer_id'] = str(customer_id)
return await self.create_alert(
tenant_id=tenant_id,
alert_type=alert_type,
severity=severity,
title=title,
message=message,
source=AlertSource.ORDERS_SERVICE,
category=AlertCategory.OPERATIONAL,
entity_id=order_id,
metadata=metadata,
**kwargs
)