""" Alert Generation Utilities for Demo Sessions Provides functions to create realistic alerts during data cloning All alert messages are in Spanish for demo purposes. """ from datetime import datetime, timezone from typing import List, Optional, Dict, Any import uuid from decimal import Decimal import structlog logger = structlog.get_logger() def format_quantity(value: float, decimals: int = 2) -> str: """ Format quantity with proper rounding to avoid floating point errors Args: value: The numeric value to format decimals: Number of decimal places (default 2) Returns: Formatted string with proper decimal representation """ return f"{round(value, decimals):.{decimals}f}" def format_currency(value: float) -> str: """ Format currency value with proper rounding Args: value: The currency value to format Returns: Formatted currency string """ return f"{round(value, 2):.2f}" class AlertSeverity: """Alert severity levels""" LOW = "low" MEDIUM = "medium" HIGH = "high" URGENT = "urgent" class AlertStatus: """Alert status values""" ACTIVE = "active" RESOLVED = "resolved" ACKNOWLEDGED = "acknowledged" IGNORED = "ignored" async def create_demo_alert( db, tenant_id: uuid.UUID, alert_type: str, severity: str, title: str, message: str, service: str, rabbitmq_client, metadata: Dict[str, Any] = None, created_at: Optional[datetime] = None ): """ Create and persist a demo alert, then publish to RabbitMQ Args: db: Database session tenant_id: Tenant UUID alert_type: Type of alert (e.g., 'expiration_imminent') severity: Alert severity level (low, medium, high, urgent) title: Alert title (in Spanish) message: Alert message (in Spanish) service: Service name that generated the alert rabbitmq_client: RabbitMQ client for publishing alerts metadata: Additional alert-specific data created_at: When the alert was created (defaults to now) Returns: Created Alert instance (dict for cross-service compatibility) """ from shared.config.rabbitmq_config import get_routing_key alert_id = uuid.uuid4() alert_created_at = created_at or datetime.now(timezone.utc) # Import here to avoid circular dependencies try: from app.models.alerts import Alert alert = Alert( id=alert_id, tenant_id=tenant_id, item_type="alert", alert_type=alert_type, severity=severity, status=AlertStatus.ACTIVE, service=service, title=title, message=message, alert_metadata=metadata or {}, created_at=alert_created_at ) db.add(alert) await db.flush() except ImportError: # If Alert model not available, skip DB insert logger.warning("Alert model not available, skipping DB insert", service=service) # Publish alert to RabbitMQ for processing by Alert Processor if rabbitmq_client: try: alert_message = { 'id': str(alert_id), 'tenant_id': str(tenant_id), 'item_type': 'alert', 'type': alert_type, 'severity': severity, 'service': service, 'title': title, 'message': message, 'metadata': metadata or {}, 'timestamp': alert_created_at.isoformat() } routing_key = get_routing_key('alert', severity, service) published = await rabbitmq_client.publish_event( exchange_name='alerts.exchange', routing_key=routing_key, event_data=alert_message ) if published: logger.info( "Demo alert published to RabbitMQ", alert_id=str(alert_id), alert_type=alert_type, severity=severity, service=service, routing_key=routing_key ) else: logger.warning( "Failed to publish demo alert to RabbitMQ", alert_id=str(alert_id), alert_type=alert_type ) except Exception as e: logger.error( "Error publishing demo alert to RabbitMQ", alert_id=str(alert_id), error=str(e), exc_info=True ) else: logger.warning("No RabbitMQ client provided, alert will not be streamed", alert_id=str(alert_id)) # Return alert dict for compatibility return { "id": str(alert_id), "tenant_id": str(tenant_id), "item_type": "alert", "alert_type": alert_type, "severity": severity, "status": AlertStatus.ACTIVE, "service": service, "title": title, "message": message, "alert_metadata": metadata or {}, "created_at": alert_created_at } async def generate_inventory_alerts( db, tenant_id: uuid.UUID, session_created_at: datetime, rabbitmq_client=None ) -> int: """ Generate inventory-related alerts for demo session Generates alerts for: - Expired stock - Expiring soon stock (<= 3 days) - Low stock levels - Overstock situations Args: db: Database session tenant_id: Virtual tenant UUID session_created_at: When the demo session was created rabbitmq_client: RabbitMQ client for publishing alerts Returns: Number of alerts created """ try: from app.models.inventory import Stock, Ingredient from sqlalchemy import select from shared.utils.demo_dates import get_days_until_expiration except ImportError: # Models not available in this context return 0 alerts_created = 0 # Query stocks with joins to ingredients result = await db.execute( select(Stock, Ingredient).join( Ingredient, Stock.ingredient_id == Ingredient.id ).where( Stock.tenant_id == tenant_id ) ) stock_ingredient_pairs = result.all() for stock, ingredient in stock_ingredient_pairs: # Expiration alerts if stock.expiration_date: days_until_expiry = get_days_until_expiration( stock.expiration_date, session_created_at ) if days_until_expiry < 0: # Expired stock qty_formatted = format_quantity(float(stock.current_quantity)) loss_formatted = format_currency(float(stock.total_cost)) if stock.total_cost else "0.00" await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="expired_stock", severity=AlertSeverity.URGENT, title=f"Stock Caducado: {ingredient.name}", message=f"El lote {stock.batch_number} caducó hace {abs(days_until_expiry)} días. " f"Cantidad: {qty_formatted} {ingredient.unit_of_measure.value}. " f"Acción requerida: Retirar inmediatamente del inventario y registrar como pérdida.", service="inventory", rabbitmq_client=rabbitmq_client, metadata={ "stock_id": str(stock.id), "ingredient_id": str(ingredient.id), "batch_number": stock.batch_number, "expiration_date": stock.expiration_date.isoformat(), "days_expired": abs(days_until_expiry), "quantity": qty_formatted, "unit": ingredient.unit_of_measure.value, "estimated_loss": loss_formatted } ) alerts_created += 1 elif days_until_expiry <= 3: # Expiring soon qty_formatted = format_quantity(float(stock.current_quantity)) await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="expiration_imminent", severity=AlertSeverity.HIGH, title=f"Próximo a Caducar: {ingredient.name}", message=f"El lote {stock.batch_number} caduca en {days_until_expiry} día{'s' if days_until_expiry > 1 else ''}. " f"Cantidad: {qty_formatted} {ingredient.unit_of_measure.value}. " f"Recomendación: Planificar uso prioritario en producción inmediata.", service="inventory", rabbitmq_client=rabbitmq_client, metadata={ "stock_id": str(stock.id), "ingredient_id": str(ingredient.id), "batch_number": stock.batch_number, "expiration_date": stock.expiration_date.isoformat(), "days_until_expiry": days_until_expiry, "quantity": qty_formatted, "unit": ingredient.unit_of_measure.value } ) alerts_created += 1 # Low stock alert if stock.current_quantity < ingredient.low_stock_threshold: shortage = ingredient.low_stock_threshold - stock.current_quantity current_qty = format_quantity(float(stock.current_quantity)) threshold_qty = format_quantity(float(ingredient.low_stock_threshold)) shortage_qty = format_quantity(float(shortage)) reorder_qty = format_quantity(float(ingredient.reorder_quantity)) await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="low_stock", severity=AlertSeverity.MEDIUM, title=f"Stock Bajo: {ingredient.name}", message=f"Stock actual: {current_qty} {ingredient.unit_of_measure.value}. " f"Umbral mínimo: {threshold_qty}. " f"Faltante: {shortage_qty} {ingredient.unit_of_measure.value}. " f"Se recomienda realizar pedido de {reorder_qty} {ingredient.unit_of_measure.value}.", service="inventory", rabbitmq_client=rabbitmq_client, metadata={ "stock_id": str(stock.id), "ingredient_id": str(ingredient.id), "current_quantity": current_qty, "threshold": threshold_qty, "reorder_point": format_quantity(float(ingredient.reorder_point)), "reorder_quantity": reorder_qty, "shortage": shortage_qty } ) alerts_created += 1 # Overstock alert (if max_stock_level is defined) if ingredient.max_stock_level and stock.current_quantity > ingredient.max_stock_level: excess = stock.current_quantity - ingredient.max_stock_level current_qty = format_quantity(float(stock.current_quantity)) max_level_qty = format_quantity(float(ingredient.max_stock_level)) excess_qty = format_quantity(float(excess)) await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="overstock", severity=AlertSeverity.LOW, title=f"Exceso de Stock: {ingredient.name}", message=f"Stock actual: {current_qty} {ingredient.unit_of_measure.value}. " f"Nivel máximo recomendado: {max_level_qty}. " f"Exceso: {excess_qty} {ingredient.unit_of_measure.value}. " f"Considerar reducir cantidad en próximos pedidos o buscar uso alternativo.", service="inventory", rabbitmq_client=rabbitmq_client, metadata={ "stock_id": str(stock.id), "ingredient_id": str(ingredient.id), "current_quantity": current_qty, "max_level": max_level_qty, "excess": excess_qty } ) alerts_created += 1 await db.flush() return alerts_created async def generate_equipment_alerts( db, tenant_id: uuid.UUID, session_created_at: datetime, rabbitmq_client=None ) -> int: """ Generate equipment-related alerts for demo session Generates alerts for: - Equipment needing maintenance - Equipment in maintenance/down status - Equipment with low efficiency Args: db: Database session tenant_id: Virtual tenant UUID session_created_at: When the demo session was created rabbitmq_client: RabbitMQ client for publishing alerts Returns: Number of alerts created """ try: from app.models.production import Equipment, EquipmentStatus from sqlalchemy import select except ImportError: return 0 alerts_created = 0 # Query equipment result = await db.execute( select(Equipment).where(Equipment.tenant_id == tenant_id) ) equipment_list = result.scalars().all() for equipment in equipment_list: # Maintenance required alert if equipment.next_maintenance_date and equipment.next_maintenance_date <= session_created_at: await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="equipment_maintenance_due", severity=AlertSeverity.MEDIUM, title=f"Mantenimiento Vencido: {equipment.name}", message=f"El equipo {equipment.name} ({equipment.type.value}) tiene mantenimiento vencido. " f"Último mantenimiento: {equipment.last_maintenance_date.strftime('%d/%m/%Y') if equipment.last_maintenance_date else 'No registrado'}. " f"Programar mantenimiento preventivo lo antes posible.", service="production", rabbitmq_client=rabbitmq_client, metadata={ "equipment_id": str(equipment.id), "equipment_name": equipment.name, "equipment_type": equipment.type.value, "last_maintenance": equipment.last_maintenance_date.isoformat() if equipment.last_maintenance_date else None, "next_maintenance": equipment.next_maintenance_date.isoformat() } ) alerts_created += 1 # Equipment status alerts if equipment.status == EquipmentStatus.MAINTENANCE: await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="equipment_in_maintenance", severity=AlertSeverity.MEDIUM, title=f"Equipo en Mantenimiento: {equipment.name}", message=f"El equipo {equipment.name} está actualmente en mantenimiento y no disponible para producción. " f"Ajustar planificación de producción según capacidad reducida.", service="production", rabbitmq_client=rabbitmq_client, metadata={ "equipment_id": str(equipment.id), "equipment_name": equipment.name, "equipment_type": equipment.type.value } ) alerts_created += 1 elif equipment.status == EquipmentStatus.DOWN: await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="equipment_down", severity=AlertSeverity.URGENT, title=f"Equipo Fuera de Servicio: {equipment.name}", message=f"URGENTE: El equipo {equipment.name} está fuera de servicio. " f"Contactar con servicio técnico inmediatamente. " f"Revisar planificación de producción y reasignar lotes a otros equipos.", service="production", rabbitmq_client=rabbitmq_client, metadata={ "equipment_id": str(equipment.id), "equipment_name": equipment.name, "equipment_type": equipment.type.value } ) alerts_created += 1 elif equipment.status == EquipmentStatus.WARNING: await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="equipment_warning", severity=AlertSeverity.MEDIUM, title=f"Advertencia de Equipo: {equipment.name}", message=f"El equipo {equipment.name} presenta signos de advertencia. " f"Eficiencia actual: {equipment.efficiency_percentage:.1f}%. " f"Monitorear de cerca y considerar inspección preventiva.", service="production", rabbitmq_client=rabbitmq_client, metadata={ "equipment_id": str(equipment.id), "equipment_name": equipment.name, "equipment_type": equipment.type.value, "efficiency": float(equipment.efficiency_percentage) if equipment.efficiency_percentage else None } ) alerts_created += 1 # Low efficiency alert if equipment.efficiency_percentage and equipment.efficiency_percentage < 80.0: efficiency_formatted = format_quantity(float(equipment.efficiency_percentage), 1) await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="equipment_low_efficiency", severity=AlertSeverity.LOW, title=f"Eficiencia Baja: {equipment.name}", message=f"El equipo {equipment.name} está operando con eficiencia reducida ({efficiency_formatted}%). " f"Eficiencia objetivo: ≥ 85%. " f"Revisar causas: limpieza, calibración, desgaste de componentes.", service="production", rabbitmq_client=rabbitmq_client, metadata={ "equipment_id": str(equipment.id), "equipment_name": equipment.name, "efficiency": efficiency_formatted } ) alerts_created += 1 await db.flush() return alerts_created async def generate_order_alerts( db, tenant_id: uuid.UUID, session_created_at: datetime, rabbitmq_client=None ) -> int: """ Generate order-related alerts for demo session Generates alerts for: - Orders with approaching delivery dates - Delayed orders - High-priority pending orders Args: db: Database session tenant_id: Virtual tenant UUID session_created_at: When the demo session was created rabbitmq_client: RabbitMQ client for publishing alerts Returns: Number of alerts created """ try: from app.models.order import CustomerOrder from sqlalchemy import select from shared.utils.demo_dates import get_days_until_expiration except ImportError: return 0 alerts_created = 0 # Query orders result = await db.execute( select(CustomerOrder).where( CustomerOrder.tenant_id == tenant_id, CustomerOrder.status.in_(['pending', 'confirmed', 'in_production']) ) ) orders = result.scalars().all() for order in orders: if order.requested_delivery_date: days_until_delivery = (order.requested_delivery_date - session_created_at).days # Approaching delivery date if 0 <= days_until_delivery <= 2 and order.status in ['pending', 'confirmed']: await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="order_delivery_soon", severity=AlertSeverity.HIGH, title=f"Entrega Próxima: Pedido {order.order_number}", message=f"El pedido {order.order_number} debe entregarse en {days_until_delivery} día{'s' if days_until_delivery > 1 else ''}. " f"Cliente: {order.customer.name if hasattr(order, 'customer') else 'N/A'}. " f"Estado actual: {order.status}. " f"Verificar que esté en producción.", service="orders", rabbitmq_client=rabbitmq_client, metadata={ "order_id": str(order.id), "order_number": order.order_number, "status": order.status, "delivery_date": order.requested_delivery_date.isoformat(), "days_until_delivery": days_until_delivery } ) alerts_created += 1 # Delayed order if days_until_delivery < 0: await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="order_delayed", severity=AlertSeverity.URGENT, title=f"Pedido Retrasado: {order.order_number}", message=f"URGENTE: El pedido {order.order_number} está retrasado {abs(days_until_delivery)} días. " f"Fecha de entrega prevista: {order.requested_delivery_date.strftime('%d/%m/%Y')}. " f"Contactar al cliente y renegociar fecha de entrega.", service="orders", rabbitmq_client=rabbitmq_client, metadata={ "order_id": str(order.id), "order_number": order.order_number, "status": order.status, "delivery_date": order.requested_delivery_date.isoformat(), "days_delayed": abs(days_until_delivery) } ) alerts_created += 1 # High priority pending orders if order.priority == 'high' and order.status == 'pending': amount_formatted = format_currency(float(order.total_amount)) await create_demo_alert( db=db, tenant_id=tenant_id, alert_type="high_priority_order_pending", severity=AlertSeverity.MEDIUM, title=f"Pedido Prioritario Pendiente: {order.order_number}", message=f"El pedido de alta prioridad {order.order_number} está pendiente de confirmación. " f"Monto: €{amount_formatted}. " f"Revisar disponibilidad de ingredientes y confirmar producción.", service="orders", rabbitmq_client=rabbitmq_client, metadata={ "order_id": str(order.id), "order_number": order.order_number, "priority": order.priority, "total_amount": amount_formatted } ) alerts_created += 1 await db.flush() return alerts_created # Utility function for cross-service alert creation async def create_alert_via_api( alert_processor_url: str, tenant_id: uuid.UUID, alert_data: Dict[str, Any], internal_api_key: str ) -> bool: """ Create an alert via the alert processor service API This function is useful when creating alerts from services that don't have direct database access to the alert processor database. Args: alert_processor_url: Base URL of alert processor service tenant_id: Tenant UUID alert_data: Alert data dictionary internal_api_key: Internal API key for service-to-service auth Returns: True if alert created successfully, False otherwise """ import httpx try: async with httpx.AsyncClient() as client: response = await client.post( f"{alert_processor_url}/internal/alerts", json={ "tenant_id": str(tenant_id), **alert_data }, headers={ "X-Internal-API-Key": internal_api_key }, timeout=5.0 ) return response.status_code == 201 except Exception: return False