Delete legacy alerts
This commit is contained in:
@@ -12,7 +12,6 @@ import {
|
|||||||
InventoryItem,
|
InventoryItem,
|
||||||
StockLevel,
|
StockLevel,
|
||||||
StockMovement,
|
StockMovement,
|
||||||
StockAlert,
|
|
||||||
InventorySearchParams,
|
InventorySearchParams,
|
||||||
CreateInventoryItemRequest,
|
CreateInventoryItemRequest,
|
||||||
UpdateInventoryItemRequest,
|
UpdateInventoryItemRequest,
|
||||||
@@ -31,7 +30,6 @@ interface UseInventoryReturn {
|
|||||||
items: InventoryItem[];
|
items: InventoryItem[];
|
||||||
stockLevels: Record<string, StockLevel>;
|
stockLevels: Record<string, StockLevel>;
|
||||||
movements: StockMovement[];
|
movements: StockMovement[];
|
||||||
alerts: StockAlert[];
|
|
||||||
dashboardData: InventoryDashboardData | null;
|
dashboardData: InventoryDashboardData | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -54,9 +52,6 @@ interface UseInventoryReturn {
|
|||||||
adjustStock: (itemId: string, adjustment: StockAdjustmentRequest) => Promise<StockMovement | null>;
|
adjustStock: (itemId: string, adjustment: StockAdjustmentRequest) => Promise<StockMovement | null>;
|
||||||
loadMovements: (params?: any) => Promise<void>;
|
loadMovements: (params?: any) => Promise<void>;
|
||||||
|
|
||||||
// Alerts
|
|
||||||
loadAlerts: () => Promise<void>;
|
|
||||||
acknowledgeAlert: (alertId: string) => Promise<boolean>;
|
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
loadDashboard: () => Promise<void>;
|
loadDashboard: () => Promise<void>;
|
||||||
@@ -69,7 +64,6 @@ interface UseInventoryReturn {
|
|||||||
|
|
||||||
interface UseInventoryDashboardReturn {
|
interface UseInventoryDashboardReturn {
|
||||||
dashboardData: InventoryDashboardData | null;
|
dashboardData: InventoryDashboardData | null;
|
||||||
alerts: StockAlert[];
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
@@ -95,7 +89,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|||||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||||
const [stockLevels, setStockLevels] = useState<Record<string, StockLevel>>({});
|
const [stockLevels, setStockLevels] = useState<Record<string, StockLevel>>({});
|
||||||
const [movements, setMovements] = useState<StockMovement[]>([]);
|
const [movements, setMovements] = useState<StockMovement[]>([]);
|
||||||
const [alerts, setAlerts] = useState<StockAlert[]>([]);
|
|
||||||
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -278,34 +271,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|||||||
}
|
}
|
||||||
}, [tenantId]);
|
}, [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
|
// Load dashboard
|
||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
@@ -337,10 +302,9 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadItems(),
|
loadItems(),
|
||||||
loadStockLevels(),
|
loadStockLevels(),
|
||||||
loadAlerts(),
|
|
||||||
loadDashboard()
|
loadDashboard()
|
||||||
]);
|
]);
|
||||||
}, [loadItems, loadStockLevels, loadAlerts, loadDashboard]);
|
}, [loadItems, loadStockLevels, loadDashboard]);
|
||||||
|
|
||||||
// Auto-load on mount
|
// Auto-load on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -354,7 +318,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|||||||
items,
|
items,
|
||||||
stockLevels,
|
stockLevels,
|
||||||
movements,
|
movements,
|
||||||
alerts,
|
|
||||||
dashboardData,
|
dashboardData,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
@@ -372,10 +335,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|||||||
adjustStock,
|
adjustStock,
|
||||||
loadMovements,
|
loadMovements,
|
||||||
|
|
||||||
// Alerts
|
|
||||||
loadAlerts,
|
|
||||||
acknowledgeAlert,
|
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
loadDashboard,
|
loadDashboard,
|
||||||
|
|
||||||
@@ -391,7 +350,6 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|||||||
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
||||||
const { tenantId } = useTenantId();
|
const { tenantId } = useTenantId();
|
||||||
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
||||||
const [alerts, setAlerts] = useState<StockAlert[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -402,13 +360,9 @@ export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [dashboard, alertsData] = await Promise.all([
|
const dashboard = await inventoryService.getDashboardData(tenantId);
|
||||||
inventoryService.getDashboardData(tenantId),
|
|
||||||
inventoryService.getStockAlerts(tenantId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
setDashboardData(dashboard);
|
setDashboardData(dashboard);
|
||||||
setAlerts(alertsData);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading dashboard';
|
const errorMessage = err.response?.data?.detail || err.message || 'Error loading dashboard';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
@@ -425,7 +379,6 @@ export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
dashboardData,
|
dashboardData,
|
||||||
alerts,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
refresh
|
refresh
|
||||||
|
|||||||
@@ -105,22 +105,6 @@ export interface StockMovement {
|
|||||||
batch_info?: StockBatch;
|
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 ==========
|
// ========== REQUEST/RESPONSE TYPES ==========
|
||||||
|
|
||||||
@@ -404,32 +388,6 @@ export class InventoryService {
|
|||||||
return apiClient.get(url);
|
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 ==========
|
// ========== 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
|
* Get restock recommendations
|
||||||
|
|||||||
@@ -344,20 +344,6 @@ export class OrdersService {
|
|||||||
return apiClient.get(`${this.basePath}/analytics/seasonal`, { params });
|
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -66,13 +66,11 @@ export interface ProductionDashboardData {
|
|||||||
equipment_in_use: number;
|
equipment_in_use: number;
|
||||||
current_efficiency: number;
|
current_efficiency: number;
|
||||||
todays_production: number;
|
todays_production: number;
|
||||||
alerts_count: number;
|
|
||||||
};
|
};
|
||||||
efficiency_trend: { date: string; efficiency: number }[];
|
efficiency_trend: { date: string; efficiency: number }[];
|
||||||
quality_trend: { date: string; quality: number }[];
|
quality_trend: { date: string; quality: number }[];
|
||||||
equipment_status: Equipment[];
|
equipment_status: Equipment[];
|
||||||
active_batches: ProductionBatch[];
|
active_batches: ProductionBatch[];
|
||||||
alerts: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchCreateRequest {
|
export interface BatchCreateRequest {
|
||||||
@@ -174,8 +172,7 @@ export class ProductionService {
|
|||||||
temperature: number;
|
temperature: number;
|
||||||
humidity: number;
|
humidity: number;
|
||||||
estimated_completion: string;
|
estimated_completion: string;
|
||||||
alerts: any[];
|
}> {
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/batches/${batchId}/status`);
|
return apiClient.get(`${this.basePath}/batches/${batchId}/status`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,20 +292,5 @@ export class ProductionService {
|
|||||||
return apiClient.get(`${this.basePath}/analytics/quality`, { params });
|
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,6 @@ export interface SupplierDashboardData {
|
|||||||
quality_scores: number[];
|
quality_scores: number[];
|
||||||
cost_savings: number[];
|
cost_savings: number[];
|
||||||
};
|
};
|
||||||
alerts: any[];
|
|
||||||
contract_expirations: { supplier_name: string; days_until_expiry: number }[];
|
contract_expirations: { supplier_name: string; days_until_expiry: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,23 +319,7 @@ export class SuppliersService {
|
|||||||
return apiClient.get(`${this.basePath}/analytics/risk-analysis`);
|
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
|
// Additional methods for hooks compatibility
|
||||||
async getSupplierStatistics(): Promise<SupplierStatistics> {
|
async getSupplierStatistics(): Promise<SupplierStatistics> {
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ const InventoryDashboardWidget: React.FC<InventoryDashboardWidgetProps> = ({
|
|||||||
onViewInventory,
|
onViewInventory,
|
||||||
className = ''
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
const { dashboardData, alerts, isLoading, error, refresh } = useInventoryDashboard();
|
const { dashboardData, isLoading, error, refresh } = useInventoryDashboard();
|
||||||
|
|
||||||
// Get alert counts
|
// Get alert counts
|
||||||
const criticalAlerts = alerts.filter(a => !a.is_acknowledged && a.severity === 'critical').length;
|
const criticalAlerts = 0;
|
||||||
const lowStockAlerts = alerts.filter(a => !a.is_acknowledged && a.alert_type === 'low_stock').length;
|
const lowStockAlerts = 0;
|
||||||
const expiringAlerts = alerts.filter(a => !a.is_acknowledged && a.alert_type === 'expiring_soon').length;
|
const expiringAlerts = 0;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export { default as CriticalAlerts } from './CriticalAlerts';
|
|
||||||
export { default as OrderSuggestions } from './OrderSuggestions';
|
export { default as OrderSuggestions } from './OrderSuggestions';
|
||||||
export { default as QuickActions } from './QuickActions';
|
export { default as QuickActions } from './QuickActions';
|
||||||
export { default as QuickOverview } from './QuickOverview';
|
export { default as QuickOverview } from './QuickOverview';
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDashboard } from '../../hooks/useDashboard';
|
import { useDashboard } from '../../hooks/useDashboard';
|
||||||
import { useOrderSuggestions } from '../../hooks/useOrderSuggestions';
|
import { useOrderSuggestions } from '../../hooks/useOrderSuggestions';
|
||||||
import { useRealAlerts } from '../../hooks/useRealAlerts';
|
|
||||||
|
|
||||||
// Import simplified components
|
// Import simplified components
|
||||||
import TodayRevenue from '../../components/simple/TodayRevenue';
|
import TodayRevenue from '../../components/simple/TodayRevenue';
|
||||||
import CriticalAlerts from '../../components/simple/CriticalAlerts';
|
|
||||||
import TodayProduction from '../../components/simple/TodayProduction';
|
import TodayProduction from '../../components/simple/TodayProduction';
|
||||||
import QuickActions from '../../components/simple/QuickActions';
|
import QuickActions from '../../components/simple/QuickActions';
|
||||||
import QuickOverview from '../../components/simple/QuickOverview';
|
import QuickOverview from '../../components/simple/QuickOverview';
|
||||||
@@ -55,11 +53,6 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|||||||
error: ordersError
|
error: ordersError
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use real API data for alerts
|
|
||||||
const {
|
|
||||||
alerts: realAlerts,
|
|
||||||
onAlertAction
|
|
||||||
} = useRealAlerts();
|
|
||||||
|
|
||||||
// Transform forecast data for production component
|
// Transform forecast data for production component
|
||||||
|
|
||||||
@@ -163,11 +156,6 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|||||||
dailyTarget={350}
|
dailyTarget={350}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Alerts - Real API Data */}
|
|
||||||
<CriticalAlerts
|
|
||||||
alerts={realAlerts}
|
|
||||||
onAlertClick={onAlertAction}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Quick Actions - Easy Access */}
|
{/* Quick Actions - Easy Access */}
|
||||||
<QuickActions
|
<QuickActions
|
||||||
@@ -243,15 +231,13 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|||||||
|
|
||||||
|
|
||||||
{/* Success Message - When Everything is Good */}
|
{/* 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="bg-green-50 border border-green-200 rounded-xl p-6 text-center">
|
<div className="text-4xl mb-2">🎉</div>
|
||||||
<div className="text-4xl mb-2">🎉</div>
|
<h4 className="font-medium text-green-900">¡Todo bajo control!</h4>
|
||||||
<h4 className="font-medium text-green-900">¡Todo bajo control!</h4>
|
<p className="text-green-700 text-sm mt-1">
|
||||||
<p className="text-green-700 text-sm mt-1">
|
Tu panadería está funcionando perfectamente.
|
||||||
No hay alertas activas. Tu panadería está funcionando perfectamente.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from '../../api/services/inventory.service';
|
} from '../../api/services/inventory.service';
|
||||||
|
|
||||||
import InventoryItemCard from '../../components/inventory/InventoryItemCard';
|
import InventoryItemCard from '../../components/inventory/InventoryItemCard';
|
||||||
import StockAlertsPanel from '../../components/inventory/StockAlertsPanel';
|
|
||||||
|
|
||||||
type ViewMode = 'grid' | 'list';
|
type ViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
@@ -51,7 +50,6 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
|
|||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
stockLevels,
|
stockLevels,
|
||||||
alerts,
|
|
||||||
dashboardData,
|
dashboardData,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
@@ -61,14 +59,12 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
|
|||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
adjustStock,
|
adjustStock,
|
||||||
acknowledgeAlert,
|
|
||||||
refresh,
|
refresh,
|
||||||
clearError
|
clearError
|
||||||
} = useInventory();
|
} = useInventory();
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
const [showAlerts, setShowAlerts] = useState(false);
|
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
search: '',
|
search: '',
|
||||||
sort_by: 'name',
|
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
|
// Get quick stats
|
||||||
const getQuickStats = () => {
|
const getQuickStats = () => {
|
||||||
const totalItems = items.length;
|
const totalItems = items.length;
|
||||||
const lowStockItems = alerts.filter(a => a.alert_type === 'low_stock' && !a.is_acknowledged).length;
|
const lowStockItems = 0;
|
||||||
const expiringItems = alerts.filter(a => a.alert_type === 'expiring_soon' && !a.is_acknowledged).length;
|
const expiringItems = 0;
|
||||||
const totalValue = dashboardData?.total_value || 0;
|
const totalValue = dashboardData?.total_value || 0;
|
||||||
|
|
||||||
return { totalItems, lowStockItems, expiringItems, totalValue };
|
return { totalItems, lowStockItems, expiringItems, totalValue };
|
||||||
@@ -195,19 +179,6 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<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
|
<button
|
||||||
onClick={() => refresh()}
|
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="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">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className={`${showAlerts ? 'lg:col-span-3' : 'lg:col-span-4'}`}>
|
<div className="lg:col-span-4">
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
@@ -534,17 +505,6 @@ const InventoryPage: React.FC<InventoryPageProps> = ({ view = 'stock-levels' })
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alerts Panel */}
|
|
||||||
{showAlerts && (
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<StockAlertsPanel
|
|
||||||
alerts={alerts}
|
|
||||||
onAcknowledge={handleAcknowledgeAlert}
|
|
||||||
onAcknowledgeAll={handleBulkAcknowledgeAlerts}
|
|
||||||
onViewItem={handleViewItemById}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ export interface Forecast {
|
|||||||
features_used?: Record<string, any>;
|
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 {
|
export interface QuickForecast {
|
||||||
product: string;
|
product: string;
|
||||||
@@ -63,12 +51,10 @@ export interface QuickForecast {
|
|||||||
interface ForecastState {
|
interface ForecastState {
|
||||||
forecasts: Forecast[];
|
forecasts: Forecast[];
|
||||||
todayForecasts: QuickForecast[];
|
todayForecasts: QuickForecast[];
|
||||||
alerts: ForecastAlert[];
|
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isGeneratingForecast: boolean;
|
isGeneratingForecast: boolean;
|
||||||
isFetchingAlerts: boolean;
|
|
||||||
|
|
||||||
// Selected filters
|
// Selected filters
|
||||||
selectedDate: string;
|
selectedDate: string;
|
||||||
@@ -93,10 +79,8 @@ interface ForecastState {
|
|||||||
const initialState: ForecastState = {
|
const initialState: ForecastState = {
|
||||||
forecasts: [],
|
forecasts: [],
|
||||||
todayForecasts: [],
|
todayForecasts: [],
|
||||||
alerts: [],
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isGeneratingForecast: false,
|
isGeneratingForecast: false,
|
||||||
isFetchingAlerts: false,
|
|
||||||
selectedDate: new Date().toISOString().split('T')[0],
|
selectedDate: new Date().toISOString().split('T')[0],
|
||||||
selectedProduct: 'all',
|
selectedProduct: 'all',
|
||||||
selectedLocation: '',
|
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(
|
export const fetchWeatherContext = createAsyncThunk(
|
||||||
'forecast/fetchWeather',
|
'forecast/fetchWeather',
|
||||||
@@ -293,12 +243,6 @@ const forecastSlice = createSlice({
|
|||||||
setCurrentWeather: (state, action: PayloadAction<ForecastState['currentWeather']>) => {
|
setCurrentWeather: (state, action: PayloadAction<ForecastState['currentWeather']>) => {
|
||||||
state.currentWeather = action.payload;
|
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) => {
|
clearForecasts: (state) => {
|
||||||
state.forecasts = [];
|
state.forecasts = [];
|
||||||
state.todayForecasts = [];
|
state.todayForecasts = [];
|
||||||
@@ -378,28 +322,6 @@ const forecastSlice = createSlice({
|
|||||||
state.error = action.error.message || 'Failed to fetch today\'s forecasts';
|
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
|
// Fetch weather context
|
||||||
builder
|
builder
|
||||||
@@ -421,7 +343,6 @@ export const {
|
|||||||
addForecast,
|
addForecast,
|
||||||
updateModelAccuracy,
|
updateModelAccuracy,
|
||||||
setCurrentWeather,
|
setCurrentWeather,
|
||||||
markAlertAsRead,
|
|
||||||
clearForecasts,
|
clearForecasts,
|
||||||
} = forecastSlice.actions;
|
} = forecastSlice.actions;
|
||||||
|
|
||||||
@@ -430,7 +351,6 @@ export default forecastSlice.reducer;
|
|||||||
// Selectors
|
// Selectors
|
||||||
export const selectForecasts = (state: { forecast: ForecastState }) => state.forecast.forecasts;
|
export const selectForecasts = (state: { forecast: ForecastState }) => state.forecast.forecasts;
|
||||||
export const selectTodayForecasts = (state: { forecast: ForecastState }) => state.forecast.todayForecasts;
|
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 selectForecastLoading = (state: { forecast: ForecastState }) => state.forecast.isLoading;
|
||||||
export const selectForecastGenerating = (state: { forecast: ForecastState }) => state.forecast.isGeneratingForecast;
|
export const selectForecastGenerating = (state: { forecast: ForecastState }) => state.forecast.isGeneratingForecast;
|
||||||
export const selectForecastError = (state: { forecast: ForecastState }) => state.forecast.error;
|
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 selectCurrentWeather = (state: { forecast: ForecastState }) => state.forecast.currentWeather;
|
||||||
export const selectModelAccuracy = (state: { forecast: ForecastState }) => state.forecast.modelAccuracy;
|
export const selectModelAccuracy = (state: { forecast: ForecastState }) => state.forecast.modelAccuracy;
|
||||||
export const selectUnacknowledgedAlerts = (state: { forecast: ForecastState }) =>
|
|
||||||
state.forecast.alerts.filter(alert => !alert.acknowledged);
|
|
||||||
@@ -12,7 +12,7 @@ import uuid
|
|||||||
from app.services.forecasting_service import EnhancedForecastingService
|
from app.services.forecasting_service import EnhancedForecastingService
|
||||||
from app.schemas.forecasts import (
|
from app.schemas.forecasts import (
|
||||||
ForecastRequest, ForecastResponse, BatchForecastRequest,
|
ForecastRequest, ForecastResponse, BatchForecastRequest,
|
||||||
BatchForecastResponse, AlertResponse
|
BatchForecastResponse
|
||||||
)
|
)
|
||||||
from shared.auth.decorators import (
|
from shared.auth.decorators import (
|
||||||
get_current_user_dep,
|
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}")
|
@router.get("/tenants/{tenant_id}/forecasts/{forecast_id}")
|
||||||
|
|||||||
@@ -51,9 +51,5 @@ class ForecastingSettings(BaseServiceSettings):
|
|||||||
TEMPERATURE_THRESHOLD_HOT: float = float(os.getenv("TEMPERATURE_THRESHOLD_HOT", "30.0"))
|
TEMPERATURE_THRESHOLD_HOT: float = float(os.getenv("TEMPERATURE_THRESHOLD_HOT", "30.0"))
|
||||||
RAIN_IMPACT_FACTOR: float = float(os.getenv("RAIN_IMPACT_FACTOR", "0.7"))
|
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()
|
settings = ForecastingSettings()
|
||||||
|
|||||||
@@ -86,29 +86,4 @@ class PredictionBatch(Base):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<PredictionBatch(id={self.id}, status={self.status})>"
|
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})>"
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Repository implementations for forecasting service
|
|||||||
from .base import ForecastingBaseRepository
|
from .base import ForecastingBaseRepository
|
||||||
from .forecast_repository import ForecastRepository
|
from .forecast_repository import ForecastRepository
|
||||||
from .prediction_batch_repository import PredictionBatchRepository
|
from .prediction_batch_repository import PredictionBatchRepository
|
||||||
from .forecast_alert_repository import ForecastAlertRepository
|
|
||||||
from .performance_metric_repository import PerformanceMetricRepository
|
from .performance_metric_repository import PerformanceMetricRepository
|
||||||
from .prediction_cache_repository import PredictionCacheRepository
|
from .prediction_cache_repository import PredictionCacheRepository
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ __all__ = [
|
|||||||
"ForecastingBaseRepository",
|
"ForecastingBaseRepository",
|
||||||
"ForecastRepository",
|
"ForecastRepository",
|
||||||
"PredictionBatchRepository",
|
"PredictionBatchRepository",
|
||||||
"ForecastAlertRepository",
|
|
||||||
"PerformanceMetricRepository",
|
"PerformanceMetricRepository",
|
||||||
"PredictionCacheRepository"
|
"PredictionCacheRepository"
|
||||||
]
|
]
|
||||||
@@ -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)}")
|
|
||||||
@@ -14,11 +14,6 @@ class BusinessType(str, Enum):
|
|||||||
INDIVIDUAL = "individual"
|
INDIVIDUAL = "individual"
|
||||||
CENTRAL_WORKSHOP = "central_workshop"
|
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):
|
class ForecastRequest(BaseModel):
|
||||||
"""Request schema for generating forecasts"""
|
"""Request schema for generating forecasts"""
|
||||||
@@ -100,16 +95,4 @@ class BatchForecastResponse(BaseModel):
|
|||||||
forecasts: Optional[List[ForecastResponse]]
|
forecasts: Optional[List[ForecastResponse]]
|
||||||
error_message: Optional[str]
|
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
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from .data_client import DataClient
|
|||||||
from .messaging import (
|
from .messaging import (
|
||||||
publish_forecast_generated,
|
publish_forecast_generated,
|
||||||
publish_batch_forecast_completed,
|
publish_batch_forecast_completed,
|
||||||
publish_forecast_alert,
|
|
||||||
ForecastingStatusPublisher
|
ForecastingStatusPublisher
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +21,5 @@ __all__ = [
|
|||||||
"DataClient",
|
"DataClient",
|
||||||
"publish_forecast_generated",
|
"publish_forecast_generated",
|
||||||
"publish_batch_forecast_completed",
|
"publish_batch_forecast_completed",
|
||||||
"publish_forecast_alert",
|
|
||||||
"ForecastingStatusPublisher"
|
"ForecastingStatusPublisher"
|
||||||
]
|
]
|
||||||
@@ -18,7 +18,6 @@ from app.services.data_client import DataClient
|
|||||||
from app.repositories import (
|
from app.repositories import (
|
||||||
ForecastRepository,
|
ForecastRepository,
|
||||||
PredictionBatchRepository,
|
PredictionBatchRepository,
|
||||||
ForecastAlertRepository,
|
|
||||||
PerformanceMetricRepository,
|
PerformanceMetricRepository,
|
||||||
PredictionCacheRepository
|
PredictionCacheRepository
|
||||||
)
|
)
|
||||||
@@ -36,7 +35,7 @@ logger = structlog.get_logger()
|
|||||||
class EnhancedForecastingService:
|
class EnhancedForecastingService:
|
||||||
"""
|
"""
|
||||||
Enhanced forecasting service using repository pattern.
|
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):
|
def __init__(self, database_manager=None):
|
||||||
@@ -55,7 +54,6 @@ class EnhancedForecastingService:
|
|||||||
return {
|
return {
|
||||||
'forecast': ForecastRepository(session),
|
'forecast': ForecastRepository(session),
|
||||||
'batch': PredictionBatchRepository(session),
|
'batch': PredictionBatchRepository(session),
|
||||||
'alert': ForecastAlertRepository(session),
|
|
||||||
'performance': PerformanceMetricRepository(session),
|
'performance': PerformanceMetricRepository(session),
|
||||||
'cache': PredictionCacheRepository(session)
|
'cache': PredictionCacheRepository(session)
|
||||||
}
|
}
|
||||||
@@ -165,15 +163,6 @@ class EnhancedForecastingService:
|
|||||||
logger.error("Failed to delete forecast", error=str(e))
|
logger.error("Failed to delete forecast", error=str(e))
|
||||||
return False
|
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]:
|
async def get_tenant_forecast_statistics(self, tenant_id: str) -> Dict[str, Any]:
|
||||||
"""Get tenant forecast statistics"""
|
"""Get tenant forecast statistics"""
|
||||||
@@ -246,7 +235,7 @@ class EnhancedForecastingService:
|
|||||||
request: ForecastRequest
|
request: ForecastRequest
|
||||||
) -> ForecastResponse:
|
) -> ForecastResponse:
|
||||||
"""
|
"""
|
||||||
Generate forecast using repository pattern with caching and alerting.
|
Generate forecast using repository pattern with caching.
|
||||||
"""
|
"""
|
||||||
start_time = datetime.utcnow()
|
start_time = datetime.utcnow()
|
||||||
|
|
||||||
@@ -339,8 +328,6 @@ class EnhancedForecastingService:
|
|||||||
expires_in_hours=24
|
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",
|
logger.info("Enhanced forecast generated successfully",
|
||||||
forecast_id=forecast.id,
|
forecast_id=forecast.id,
|
||||||
@@ -398,8 +385,6 @@ class EnhancedForecastingService:
|
|||||||
# Get forecast summary
|
# Get forecast summary
|
||||||
forecast_summary = await repos['forecast'].get_forecast_summary(tenant_id)
|
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
|
# Get batch statistics
|
||||||
batch_stats = await repos['batch'].get_batch_statistics(tenant_id)
|
batch_stats = await repos['batch'].get_batch_statistics(tenant_id)
|
||||||
@@ -415,7 +400,6 @@ class EnhancedForecastingService:
|
|||||||
return {
|
return {
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"forecast_analytics": forecast_summary,
|
"forecast_analytics": forecast_summary,
|
||||||
"alert_analytics": alert_stats,
|
|
||||||
"batch_analytics": batch_stats,
|
"batch_analytics": batch_stats,
|
||||||
"cache_performance": cache_stats,
|
"cache_performance": cache_stats,
|
||||||
"performance_trends": performance_trends,
|
"performance_trends": performance_trends,
|
||||||
@@ -469,51 +453,6 @@ class EnhancedForecastingService:
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise DatabaseError(f"Failed to create batch: {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:
|
def _create_forecast_response_from_cache(self, cache_entry) -> ForecastResponse:
|
||||||
"""Create forecast response from cached entry"""
|
"""Create forecast response from cached entry"""
|
||||||
|
|||||||
@@ -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")
|
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())
|
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]):
|
async def publish_batch_completed(data: Dict[str, Any]):
|
||||||
"""Publish batch forecast completed event"""
|
"""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))
|
logger.error("Failed to publish batch forecast event", error=str(e))
|
||||||
return False
|
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
|
# Publisher class for compatibility
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from typing import List, Optional, Dict, Any
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from shared.notifications.alert_integration import AlertIntegration
|
|
||||||
from shared.database.transactions import transactional
|
from shared.database.transactions import transactional
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -45,7 +44,7 @@ class FoodSafetyService:
|
|||||||
"""Service for food safety and compliance operations"""
|
"""Service for food safety and compliance operations"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.alert_integration = AlertIntegration()
|
pass
|
||||||
|
|
||||||
# ===== COMPLIANCE MANAGEMENT =====
|
# ===== COMPLIANCE MANAGEMENT =====
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,6 @@ class OrdersSettings(BaseServiceSettings):
|
|||||||
MAX_ORDER_VALUE: float = float(os.getenv("MAX_ORDER_VALUE", "100000.0"))
|
MAX_ORDER_VALUE: float = float(os.getenv("MAX_ORDER_VALUE", "100000.0"))
|
||||||
VALIDATE_PRODUCT_AVAILABILITY: bool = os.getenv("VALIDATE_PRODUCT_AVAILABILITY", "true").lower() == "true"
|
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 and Pricing
|
||||||
PAYMENT_VALIDATION_ENABLED: bool = os.getenv("PAYMENT_VALIDATION_ENABLED", "true").lower() == "true"
|
PAYMENT_VALIDATION_ENABLED: bool = os.getenv("PAYMENT_VALIDATION_ENABLED", "true").lower() == "true"
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ async def init_database():
|
|||||||
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
|
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
|
||||||
from app.models.customer import Customer, CustomerContact
|
from app.models.customer import Customer, CustomerContact
|
||||||
from app.models.procurement import ProcurementPlan, ProcurementRequirement
|
from app.models.procurement import ProcurementPlan, ProcurementRequirement
|
||||||
from app.models.alerts import OrderAlert
|
|
||||||
|
|
||||||
# Create all tables
|
# Create all tables
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -17,7 +17,6 @@ from shared.clients import (
|
|||||||
ProductionServiceClient,
|
ProductionServiceClient,
|
||||||
SalesServiceClient
|
SalesServiceClient
|
||||||
)
|
)
|
||||||
from shared.notifications.alert_integration import AlertIntegration
|
|
||||||
from shared.database.transactions import transactional
|
from shared.database.transactions import transactional
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -52,7 +51,6 @@ class OrdersService:
|
|||||||
inventory_client: InventoryServiceClient,
|
inventory_client: InventoryServiceClient,
|
||||||
production_client: ProductionServiceClient,
|
production_client: ProductionServiceClient,
|
||||||
sales_client: SalesServiceClient,
|
sales_client: SalesServiceClient,
|
||||||
alert_integration: AlertIntegration
|
|
||||||
):
|
):
|
||||||
self.order_repo = order_repo
|
self.order_repo = order_repo
|
||||||
self.customer_repo = customer_repo
|
self.customer_repo = customer_repo
|
||||||
@@ -61,7 +59,6 @@ class OrdersService:
|
|||||||
self.inventory_client = inventory_client
|
self.inventory_client = inventory_client
|
||||||
self.production_client = production_client
|
self.production_client = production_client
|
||||||
self.sales_client = sales_client
|
self.sales_client = sales_client
|
||||||
self.alert_integration = alert_integration
|
|
||||||
|
|
||||||
@transactional
|
@transactional
|
||||||
async def create_order(
|
async def create_order(
|
||||||
@@ -137,8 +134,6 @@ class OrdersService:
|
|||||||
if business_model:
|
if business_model:
|
||||||
order.business_model = 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
|
# 10. Integrate with production service if auto-processing is enabled
|
||||||
if settings.ORDER_PROCESSING_ENABLED:
|
if settings.ORDER_PROCESSING_ENABLED:
|
||||||
@@ -440,46 +435,6 @@ class OrdersService:
|
|||||||
# Fallback to UUID
|
# Fallback to UUID
|
||||||
return f"ORD-{uuid.uuid4().hex[:8].upper()}"
|
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):
|
async def _notify_production_service(self, order):
|
||||||
"""Notify production service of new order"""
|
"""Notify production service of new order"""
|
||||||
@@ -526,21 +481,3 @@ class OrdersService:
|
|||||||
order_id=str(order.id),
|
order_id=str(order.id),
|
||||||
error=str(e))
|
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))
|
|
||||||
@@ -14,12 +14,10 @@ import structlog
|
|||||||
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.production_service import ProductionService
|
from app.services.production_service import ProductionService
|
||||||
from app.services.production_alert_service import ProductionAlertService
|
|
||||||
from app.schemas.production import (
|
from app.schemas.production import (
|
||||||
ProductionBatchCreate, ProductionBatchUpdate, ProductionBatchStatusUpdate,
|
ProductionBatchCreate, ProductionBatchUpdate, ProductionBatchStatusUpdate,
|
||||||
ProductionBatchResponse, ProductionBatchListResponse,
|
ProductionBatchResponse, ProductionBatchListResponse,
|
||||||
DailyProductionRequirements, ProductionDashboardSummary, ProductionMetrics,
|
DailyProductionRequirements, ProductionDashboardSummary, ProductionMetrics,
|
||||||
ProductionAlertResponse, ProductionAlertListResponse
|
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
@@ -34,10 +32,6 @@ def get_production_service() -> ProductionService:
|
|||||||
return ProductionService(database_manager, settings)
|
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")
|
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")
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ class ProductionSettings(BaseServiceSettings):
|
|||||||
HOLIDAY_PRODUCTION_FACTOR: float = float(os.getenv("HOLIDAY_PRODUCTION_FACTOR", "0.3"))
|
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"))
|
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 Management
|
||||||
COST_TRACKING_ENABLED: bool = os.getenv("COST_TRACKING_ENABLED", "true").lower() == "true"
|
COST_TRACKING_ENABLED: bool = os.getenv("COST_TRACKING_ENABLED", "true").lower() == "true"
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ from .production import (
|
|||||||
ProductionBatch,
|
ProductionBatch,
|
||||||
ProductionSchedule,
|
ProductionSchedule,
|
||||||
ProductionCapacity,
|
ProductionCapacity,
|
||||||
QualityCheck,
|
QualityCheck
|
||||||
ProductionAlert
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ProductionBatch",
|
"ProductionBatch",
|
||||||
"ProductionSchedule",
|
"ProductionSchedule",
|
||||||
"ProductionCapacity",
|
"ProductionCapacity",
|
||||||
"QualityCheck",
|
"QualityCheck"
|
||||||
"ProductionAlert"
|
|
||||||
]
|
]
|
||||||
@@ -35,12 +35,6 @@ class ProductionPriority(str, enum.Enum):
|
|||||||
URGENT = "urgent"
|
URGENT = "urgent"
|
||||||
|
|
||||||
|
|
||||||
class AlertSeverity(str, enum.Enum):
|
|
||||||
"""Alert severity levels"""
|
|
||||||
LOW = "low"
|
|
||||||
MEDIUM = "medium"
|
|
||||||
HIGH = "high"
|
|
||||||
CRITICAL = "critical"
|
|
||||||
|
|
||||||
|
|
||||||
class ProductionBatch(Base):
|
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,
|
|
||||||
}
|
|
||||||
@@ -9,12 +9,10 @@ from .production_batch_repository import ProductionBatchRepository
|
|||||||
from .production_schedule_repository import ProductionScheduleRepository
|
from .production_schedule_repository import ProductionScheduleRepository
|
||||||
from .production_capacity_repository import ProductionCapacityRepository
|
from .production_capacity_repository import ProductionCapacityRepository
|
||||||
from .quality_check_repository import QualityCheckRepository
|
from .quality_check_repository import QualityCheckRepository
|
||||||
from .production_alert_repository import ProductionAlertRepository
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ProductionBatchRepository",
|
"ProductionBatchRepository",
|
||||||
"ProductionScheduleRepository",
|
"ProductionScheduleRepository",
|
||||||
"ProductionCapacityRepository",
|
"ProductionCapacityRepository",
|
||||||
"QualityCheckRepository",
|
"QualityCheckRepository",
|
||||||
"ProductionAlertRepository"
|
|
||||||
]
|
]
|
||||||
@@ -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)}")
|
|
||||||
@@ -31,12 +31,6 @@ class ProductionPriorityEnum(str, Enum):
|
|||||||
URGENT = "urgent"
|
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
|
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
|
active_batches: int
|
||||||
todays_production_plan: List[Dict[str, Any]]
|
todays_production_plan: List[Dict[str, Any]]
|
||||||
capacity_utilization: float
|
capacity_utilization: float
|
||||||
current_alerts: int
|
|
||||||
on_time_completion_rate: float
|
on_time_completion_rate: float
|
||||||
average_quality_score: float
|
average_quality_score: float
|
||||||
total_output_today: float
|
total_output_today: float
|
||||||
@@ -406,9 +344,3 @@ class QualityCheckListResponse(BaseModel):
|
|||||||
page_size: int
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
class ProductionAlertListResponse(BaseModel):
|
|
||||||
"""Schema for production alert list response"""
|
|
||||||
alerts: List[ProductionAlertResponse]
|
|
||||||
total_count: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
@@ -6,9 +6,7 @@ Business logic services
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .production_service import ProductionService
|
from .production_service import ProductionService
|
||||||
from .production_alert_service import ProductionAlertService
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ProductionService",
|
"ProductionService"
|
||||||
"ProductionAlertService"
|
|
||||||
]
|
]
|
||||||
@@ -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
|
|
||||||
@@ -205,7 +205,6 @@ class ProductionService:
|
|||||||
active_batches=len(active_batches),
|
active_batches=len(active_batches),
|
||||||
todays_production_plan=todays_plan,
|
todays_production_plan=todays_plan,
|
||||||
capacity_utilization=85.0, # TODO: Calculate from actual capacity data
|
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),
|
on_time_completion_rate=weekly_metrics.get("on_time_completion_rate", 0),
|
||||||
average_quality_score=8.5, # TODO: Get from quality checks
|
average_quality_score=8.5, # TODO: Get from quality checks
|
||||||
total_output_today=sum(b.actual_quantity or 0 for b in todays_batches),
|
total_output_today=sum(b.actual_quantity or 0 for b in todays_batches),
|
||||||
|
|||||||
@@ -5,18 +5,4 @@
|
|||||||
Shared Notifications Module - Alert integration using existing notification service
|
Shared Notifications Module - Alert integration using existing notification service
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .alert_integration import (
|
__all__ = []
|
||||||
AlertIntegration,
|
|
||||||
AlertSeverity,
|
|
||||||
AlertType,
|
|
||||||
AlertCategory,
|
|
||||||
AlertSource
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'AlertIntegration',
|
|
||||||
'AlertSeverity',
|
|
||||||
'AlertType',
|
|
||||||
'AlertCategory',
|
|
||||||
'AlertSource'
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user