New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

@@ -277,9 +277,34 @@ class BaseAlertService:
# Publishing (Updated for type)
async def publish_item(self, tenant_id: UUID, item: Dict[str, Any], item_type: str = 'alert'):
"""Publish alert or recommendation to RabbitMQ with deduplication"""
"""Publish alert or recommendation to RabbitMQ with deduplication and validation"""
try:
# Validate alert structure before publishing
from shared.schemas.alert_types import RawAlert
try:
raw_alert = RawAlert(
tenant_id=str(tenant_id),
alert_type=item.get('type'),
title=item.get('title'),
message=item.get('message'),
service=self.config.SERVICE_NAME,
actions=item.get('actions', []),
alert_metadata=item.get('metadata', {}),
item_type=item_type
)
# Validation passed, continue with validated data
logger.debug("Alert schema validation passed",
service=self.config.SERVICE_NAME,
alert_type=item.get('type'))
except Exception as validation_error:
logger.error("Alert schema validation failed",
service=self.config.SERVICE_NAME,
alert_type=item.get('type'),
error=str(validation_error))
self._errors_count += 1
return False
# Generate proper deduplication key based on alert type and specific identifiers
unique_id = self._generate_unique_identifier(item)
item_key = f"{tenant_id}:{item_type}:{item['type']}:{unique_id}"
@@ -350,6 +375,7 @@ class BaseAlertService:
metadata = item.get('metadata', {})
# Generate unique identifier based on alert type
# Inventory alerts
if alert_type == 'overstock_warning':
return metadata.get('ingredient_id', '')
elif alert_type == 'critical_stock_shortage' or alert_type == 'low_stock_warning':
@@ -377,6 +403,47 @@ class BaseAlertService:
return f"opt:{metadata.get('ingredient_id', '')}:{metadata.get('recommendation_type', '')}"
elif alert_type == 'waste_reduction':
return f"waste:{metadata.get('ingredient_id', '')}"
# Procurement alerts
elif alert_type == 'procurement_pos_pending_approval':
# Use hash of PO IDs for grouped alerts
pos = metadata.get('pos', [])
if pos:
po_ids = sorted([str(po.get('po_id', '')) for po in pos])
import hashlib
return hashlib.md5(':'.join(po_ids).encode()).hexdigest()[:16]
return ''
elif alert_type == 'procurement_approval_reminder':
return metadata.get('po_id', '')
elif alert_type == 'procurement_critical_po':
return metadata.get('po_id', '')
elif alert_type == 'procurement_po_approved':
return metadata.get('po_id', '')
elif alert_type == 'procurement_auto_approval_summary':
# Daily summary - use date as identifier
summary_date = metadata.get('summary_date', '')[:10] # Date only
return f"summary:{summary_date}"
# Production alerts
elif alert_type in ['severe_capacity_overload', 'capacity_overload', 'near_capacity']:
return f"capacity:{metadata.get('planned_date', '')}"
elif alert_type == 'production_delay':
return metadata.get('batch_id', '')
elif alert_type == 'quality_control_failure':
return metadata.get('quality_check_id', '')
elif alert_type in ['equipment_failure', 'maintenance_required', 'low_equipment_efficiency']:
return metadata.get('equipment_id', '')
elif alert_type == 'production_ingredient_shortage':
return metadata.get('ingredient_id', '')
# Forecasting alerts
elif alert_type in ['demand_surge_weekend', 'holiday_preparation', 'demand_spike_detected', 'unexpected_demand_spike']:
return f"{alert_type}:{metadata.get('product_name', '')}:{metadata.get('forecast_date', '')}"
elif alert_type == 'weather_impact_alert':
return f"weather:{metadata.get('forecast_date', '')}"
elif alert_type == 'severe_weather_impact':
return f"severe_weather:{metadata.get('weather_type', '')}:{metadata.get('duration_hours', '')}"
else:
# Fallback to generic metadata.id or empty string
return metadata.get('id', '')
@@ -485,25 +552,20 @@ class BaseAlertService:
class AlertServiceMixin:
"""Mixin providing common alert helper methods"""
def format_spanish_message(self, template_key: str, **kwargs) -> Dict[str, Any]:
"""Format Spanish alert message"""
from shared.alerts.templates import format_item_message
return format_item_message(template_key, 'es', **kwargs)
def get_business_hours_severity(self, base_severity: str) -> str:
"""Adjust severity based on business hours"""
current_hour = datetime.now().hour
# Reduce non-critical severity outside business hours (7-20)
if not (7 <= current_hour <= 20):
if base_severity == 'medium':
return 'low'
elif base_severity == 'high' and current_hour < 6 or current_hour > 22:
return 'medium'
return base_severity
def should_send_recommendation(self, tenant_id: UUID, rec_type: str) -> bool:
"""Check if recommendation should be sent based on tenant preferences"""
# Implement tenant-specific recommendation frequency limits

View File

@@ -0,0 +1,948 @@
"""
Context-Aware Alert Message Templates with i18n Support
This module generates parametrized alert messages that can be translated by the frontend.
Instead of hardcoded Spanish messages, we generate structured message keys and parameters.
Messages are generated AFTER enrichment, leveraging:
- Orchestrator context (AI actions already taken)
- Business impact (financial, customers affected)
- Urgency context (hours until consequence, actual dates)
- User agency (supplier contacts, external dependencies)
Frontend uses i18n to translate message keys with parameters.
"""
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List
from shared.schemas.alert_types import (
EnrichedAlert, OrchestratorContext, BusinessImpact,
UrgencyContext, UserAgency, TrendContext, SmartAction
)
import structlog
logger = structlog.get_logger()
def format_date_spanish(dt: datetime) -> str:
"""Format datetime in Spanish (for backwards compatibility)"""
days = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
months = ["enero", "febrero", "marzo", "abril", "mayo", "junio",
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]
day_name = days[dt.weekday()]
month_name = months[dt.month - 1]
return f"{day_name} {dt.day} de {month_name}"
def format_iso_date(dt: datetime) -> str:
"""Format datetime as ISO date for frontend i18n"""
return dt.strftime('%Y-%m-%d')
def get_production_date(metadata: Dict[str, Any], default_days: int = 1) -> datetime:
"""Get actual production date from metadata or estimate"""
if metadata.get('production_date'):
if isinstance(metadata['production_date'], str):
return datetime.fromisoformat(metadata['production_date'])
return metadata['production_date']
else:
return datetime.now() + timedelta(days=default_days)
class ContextualMessageGenerator:
"""Generates context-aware parametrized messages for i18n"""
@staticmethod
def generate_message_data(enriched: EnrichedAlert) -> Dict[str, Any]:
"""
Generate contextual message data with i18n support
Returns dict with:
- title_key: i18n key for title
- title_params: parameters for title translation
- message_key: i18n key for message
- message_params: parameters for message translation
- fallback_title: fallback if i18n not available
- fallback_message: fallback if i18n not available
"""
alert_type = enriched.alert_type
# Dispatch to specific generator based on alert type
generators = {
# Inventory alerts
'critical_stock_shortage': ContextualMessageGenerator._stock_shortage,
'low_stock_warning': ContextualMessageGenerator._low_stock,
'stock_depleted_by_order': ContextualMessageGenerator._stock_depleted,
'production_ingredient_shortage': ContextualMessageGenerator._ingredient_shortage,
'expired_products': ContextualMessageGenerator._expired_products,
# Production alerts
'production_delay': ContextualMessageGenerator._production_delay,
'equipment_failure': ContextualMessageGenerator._equipment_failure,
'maintenance_required': ContextualMessageGenerator._maintenance_required,
'low_equipment_efficiency': ContextualMessageGenerator._low_efficiency,
'order_overload': ContextualMessageGenerator._order_overload,
# Supplier alerts
'supplier_delay': ContextualMessageGenerator._supplier_delay,
# Procurement alerts
'po_approval_needed': ContextualMessageGenerator._po_approval_needed,
'production_batch_start': ContextualMessageGenerator._production_batch_start,
# Environmental alerts
'temperature_breach': ContextualMessageGenerator._temperature_breach,
# Forecasting alerts
'demand_surge_weekend': ContextualMessageGenerator._demand_surge,
'weather_impact_alert': ContextualMessageGenerator._weather_impact,
'holiday_preparation': ContextualMessageGenerator._holiday_prep,
'severe_weather_impact': ContextualMessageGenerator._severe_weather,
'unexpected_demand_spike': ContextualMessageGenerator._demand_spike,
'demand_pattern_optimization': ContextualMessageGenerator._demand_pattern,
# Recommendations
'inventory_optimization': ContextualMessageGenerator._inventory_optimization,
'production_efficiency': ContextualMessageGenerator._production_efficiency,
'sales_opportunity': ContextualMessageGenerator._sales_opportunity,
'seasonal_adjustment': ContextualMessageGenerator._seasonal_adjustment,
'cost_reduction': ContextualMessageGenerator._cost_reduction,
'waste_reduction': ContextualMessageGenerator._waste_reduction,
'quality_improvement': ContextualMessageGenerator._quality_improvement,
'customer_satisfaction': ContextualMessageGenerator._customer_satisfaction,
'energy_optimization': ContextualMessageGenerator._energy_optimization,
'staff_optimization': ContextualMessageGenerator._staff_optimization,
}
generator_func = generators.get(alert_type)
if generator_func:
return generator_func(enriched)
else:
# Fallback for unknown alert types
return {
'title_key': f'alerts.{alert_type}.title',
'title_params': {},
'message_key': f'alerts.{alert_type}.message',
'message_params': {'alert_type': alert_type},
'fallback_title': f"Alerta: {alert_type}",
'fallback_message': f"Se detectó una situación que requiere atención: {alert_type}"
}
# ===================================================================
# INVENTORY ALERTS
# ===================================================================
@staticmethod
def _stock_shortage(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Critical stock shortage with AI context"""
metadata = enriched.alert_metadata
orch = enriched.orchestrator_context
urgency = enriched.urgency_context
agency = enriched.user_agency
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
current_stock = round(metadata.get('current_stock', 0), 1)
required_stock = round(metadata.get('required_stock', metadata.get('tomorrow_needed', 0)), 1)
# Base parameters
params = {
'ingredient_name': ingredient_name,
'current_stock': current_stock,
'required_stock': required_stock
}
# Determine message variant based on context
if orch and orch.already_addressed and orch.action_type == "purchase_order":
# AI already created PO
params['po_id'] = orch.action_id
params['po_amount'] = metadata.get('po_amount', 0)
if orch.delivery_date:
params['delivery_date'] = format_iso_date(orch.delivery_date)
params['delivery_day_name'] = format_date_spanish(orch.delivery_date)
if orch.action_status == "pending_approval":
message_key = 'alerts.critical_stock_shortage.message_with_po_pending'
else:
message_key = 'alerts.critical_stock_shortage.message_with_po_created'
elif urgency and urgency.time_until_consequence_hours:
# Time-specific message
hours = urgency.time_until_consequence_hours
params['hours_until'] = round(hours, 1)
message_key = 'alerts.critical_stock_shortage.message_with_hours'
elif metadata.get('production_date'):
# Date-specific message
prod_date = get_production_date(metadata)
params['production_date'] = format_iso_date(prod_date)
params['production_day_name'] = format_date_spanish(prod_date)
message_key = 'alerts.critical_stock_shortage.message_with_date'
else:
# Generic message
message_key = 'alerts.critical_stock_shortage.message_generic'
# Add supplier contact if available
if agency and agency.requires_external_party and agency.external_party_contact:
params['supplier_name'] = agency.external_party_name
params['supplier_contact'] = agency.external_party_contact
return {
'title_key': 'alerts.critical_stock_shortage.title',
'title_params': {'ingredient_name': ingredient_name},
'message_key': message_key,
'message_params': params,
'fallback_title': f"🚨 Stock Crítico: {ingredient_name}",
'fallback_message': f"Solo {current_stock}kg de {ingredient_name} disponibles (necesitas {required_stock}kg)."
}
@staticmethod
def _low_stock(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Low stock warning"""
metadata = enriched.alert_metadata
orch = enriched.orchestrator_context
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
current_stock = round(metadata.get('current_stock', 0), 1)
minimum_stock = round(metadata.get('minimum_stock', 0), 1)
params = {
'ingredient_name': ingredient_name,
'current_stock': current_stock,
'minimum_stock': minimum_stock
}
if orch and orch.already_addressed and orch.action_type == "purchase_order":
params['po_id'] = orch.action_id
message_key = 'alerts.low_stock.message_with_po'
else:
message_key = 'alerts.low_stock.message_generic'
return {
'title_key': 'alerts.low_stock.title',
'title_params': {'ingredient_name': ingredient_name},
'message_key': message_key,
'message_params': params,
'fallback_title': f"⚠️ Stock Bajo: {ingredient_name}",
'fallback_message': f"Stock de {ingredient_name}: {current_stock}kg (mínimo: {minimum_stock}kg)."
}
@staticmethod
def _stock_depleted(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Stock depleted by order"""
metadata = enriched.alert_metadata
agency = enriched.user_agency
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
order_id = metadata.get('order_id', '???')
current_stock = round(metadata.get('current_stock', 0), 1)
minimum_stock = round(metadata.get('minimum_stock', 0), 1)
params = {
'ingredient_name': ingredient_name,
'order_id': order_id,
'current_stock': current_stock,
'minimum_stock': minimum_stock
}
if agency and agency.requires_external_party and agency.external_party_contact:
params['supplier_name'] = agency.external_party_name
params['supplier_contact'] = agency.external_party_contact
message_key = 'alerts.stock_depleted.message_with_supplier'
else:
message_key = 'alerts.stock_depleted.message_generic'
return {
'title_key': 'alerts.stock_depleted.title',
'title_params': {'ingredient_name': ingredient_name},
'message_key': message_key,
'message_params': params,
'fallback_title': f"⚠️ Stock Agotado por Pedido: {ingredient_name}",
'fallback_message': f"El pedido #{order_id} agotaría el stock de {ingredient_name}."
}
@staticmethod
def _ingredient_shortage(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Production ingredient shortage"""
metadata = enriched.alert_metadata
impact = enriched.business_impact
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
shortage_amount = round(metadata.get('shortage_amount', 0), 1)
affected_batches = metadata.get('affected_batches_count', 0)
params = {
'ingredient_name': ingredient_name,
'shortage_amount': shortage_amount,
'affected_batches': affected_batches
}
if impact and impact.affected_customers:
params['customer_count'] = len(impact.affected_customers)
params['customer_names'] = ', '.join(impact.affected_customers[:2])
if len(impact.affected_customers) > 2:
params['additional_customers'] = len(impact.affected_customers) - 2
message_key = 'alerts.ingredient_shortage.message_with_customers'
else:
message_key = 'alerts.ingredient_shortage.message_generic'
return {
'title_key': 'alerts.ingredient_shortage.title',
'title_params': {'ingredient_name': ingredient_name},
'message_key': message_key,
'message_params': params,
'fallback_title': f"🚨 Escasez en Producción: {ingredient_name}",
'fallback_message': f"Faltan {shortage_amount}kg de {ingredient_name}."
}
@staticmethod
def _expired_products(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Expired products alert"""
metadata = enriched.alert_metadata
product_count = metadata.get('product_count', 0)
expired_items = metadata.get('expired_items', [])
params = {
'product_count': product_count
}
if len(expired_items) > 0:
params['product_names'] = ', '.join([item.get('name', 'Producto') for item in expired_items[:2]])
if len(expired_items) > 2:
params['additional_count'] = len(expired_items) - 2
message_key = 'alerts.expired_products.message_with_names'
else:
message_key = 'alerts.expired_products.message_generic'
return {
'title_key': 'alerts.expired_products.title',
'title_params': {},
'message_key': message_key,
'message_params': params,
'fallback_title': "📅 Productos Caducados",
'fallback_message': f"{product_count} producto(s) caducado(s). Retirar inmediatamente."
}
# ===================================================================
# PRODUCTION ALERTS
# ===================================================================
@staticmethod
def _production_delay(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Production delay with affected orders"""
metadata = enriched.alert_metadata
impact = enriched.business_impact
batch_name = metadata.get('batch_name', 'Lote')
delay_minutes = metadata.get('delay_minutes', 0)
params = {
'batch_name': batch_name,
'delay_minutes': delay_minutes
}
if impact and impact.affected_customers:
params['customer_names'] = ', '.join(impact.affected_customers[:2])
if len(impact.affected_customers) > 2:
params['additional_count'] = len(impact.affected_customers) - 2
message_key = 'alerts.production_delay.message_with_customers'
elif impact and impact.affected_orders:
params['affected_orders'] = impact.affected_orders
message_key = 'alerts.production_delay.message_with_orders'
else:
message_key = 'alerts.production_delay.message_generic'
return {
'title_key': 'alerts.production_delay.title',
'title_params': {},
'message_key': message_key,
'message_params': params,
'fallback_title': "⏰ Retraso en Producción",
'fallback_message': f"Lote {batch_name} con {delay_minutes} minutos de retraso."
}
@staticmethod
def _equipment_failure(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Equipment failure"""
metadata = enriched.alert_metadata
impact = enriched.business_impact
equipment_name = metadata.get('equipment_name', 'Equipo')
params = {
'equipment_name': equipment_name
}
if impact and impact.production_batches_at_risk:
params['batch_count'] = len(impact.production_batches_at_risk)
message_key = 'alerts.equipment_failure.message_with_batches'
else:
message_key = 'alerts.equipment_failure.message_generic'
return {
'title_key': 'alerts.equipment_failure.title',
'title_params': {'equipment_name': equipment_name},
'message_key': message_key,
'message_params': params,
'fallback_title': f"⚙️ Fallo de Equipo: {equipment_name}",
'fallback_message': f"{equipment_name} no está funcionando correctamente."
}
@staticmethod
def _maintenance_required(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Maintenance required"""
metadata = enriched.alert_metadata
urgency = enriched.urgency_context
equipment_name = metadata.get('equipment_name', 'Equipo')
params = {
'equipment_name': equipment_name
}
if urgency and urgency.time_until_consequence_hours:
params['hours_until'] = round(urgency.time_until_consequence_hours, 1)
message_key = 'alerts.maintenance_required.message_with_hours'
else:
params['days_until'] = metadata.get('days_until_maintenance', 0)
message_key = 'alerts.maintenance_required.message_with_days'
return {
'title_key': 'alerts.maintenance_required.title',
'title_params': {'equipment_name': equipment_name},
'message_key': message_key,
'message_params': params,
'fallback_title': f"🔧 Mantenimiento Requerido: {equipment_name}",
'fallback_message': f"Equipo {equipment_name} requiere mantenimiento."
}
@staticmethod
def _low_efficiency(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Low equipment efficiency"""
metadata = enriched.alert_metadata
equipment_name = metadata.get('equipment_name', 'Equipo')
efficiency = round(metadata.get('efficiency_percent', 0), 1)
return {
'title_key': 'alerts.low_efficiency.title',
'title_params': {'equipment_name': equipment_name},
'message_key': 'alerts.low_efficiency.message',
'message_params': {
'equipment_name': equipment_name,
'efficiency_percent': efficiency
},
'fallback_title': f"📉 Baja Eficiencia: {equipment_name}",
'fallback_message': f"Eficiencia del {equipment_name} bajó a {efficiency}%."
}
@staticmethod
def _order_overload(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Order capacity overload"""
metadata = enriched.alert_metadata
impact = enriched.business_impact
percentage = round(metadata.get('percentage', 0), 1)
params = {
'percentage': percentage
}
if impact and impact.affected_orders:
params['affected_orders'] = impact.affected_orders
message_key = 'alerts.order_overload.message_with_orders'
else:
message_key = 'alerts.order_overload.message_generic'
return {
'title_key': 'alerts.order_overload.title',
'title_params': {},
'message_key': message_key,
'message_params': params,
'fallback_title': "📋 Sobrecarga de Pedidos",
'fallback_message': f"Capacidad excedida en {percentage}%."
}
# ===================================================================
# SUPPLIER ALERTS
# ===================================================================
@staticmethod
def _supplier_delay(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Supplier delivery delay"""
metadata = enriched.alert_metadata
impact = enriched.business_impact
agency = enriched.user_agency
supplier_name = metadata.get('supplier_name', 'Proveedor')
hours = round(metadata.get('hours', metadata.get('delay_hours', 0)), 0)
products = metadata.get('products', metadata.get('affected_products', ''))
params = {
'supplier_name': supplier_name,
'hours': hours,
'products': products
}
if impact and impact.production_batches_at_risk:
params['batch_count'] = len(impact.production_batches_at_risk)
if agency and agency.external_party_contact:
params['supplier_contact'] = agency.external_party_contact
message_key = 'alerts.supplier_delay.message'
return {
'title_key': 'alerts.supplier_delay.title',
'title_params': {'supplier_name': supplier_name},
'message_key': message_key,
'message_params': params,
'fallback_title': f"🚚 Retraso de Proveedor: {supplier_name}",
'fallback_message': f"Entrega de {supplier_name} retrasada {hours} hora(s)."
}
# ===================================================================
# PROCUREMENT ALERTS
# ===================================================================
@staticmethod
def _po_approval_needed(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Purchase order approval needed"""
metadata = enriched.alert_metadata
po_number = metadata.get('po_number', 'PO-XXXX')
supplier_name = metadata.get('supplier_name', 'Proveedor')
total_amount = metadata.get('total_amount', 0)
currency = metadata.get('currency', '')
required_delivery_date = metadata.get('required_delivery_date')
# Format required delivery date for i18n
required_delivery_date_iso = None
if required_delivery_date:
if isinstance(required_delivery_date, str):
try:
dt = datetime.fromisoformat(required_delivery_date.replace('Z', '+00:00'))
required_delivery_date_iso = format_iso_date(dt)
except:
required_delivery_date_iso = required_delivery_date
elif isinstance(required_delivery_date, datetime):
required_delivery_date_iso = format_iso_date(required_delivery_date)
params = {
'po_number': po_number,
'supplier_name': supplier_name,
'total_amount': round(total_amount, 2),
'currency': currency,
'required_delivery_date': required_delivery_date_iso or 'fecha no especificada'
}
return {
'title_key': 'alerts.po_approval_needed.title',
'title_params': {'po_number': po_number},
'message_key': 'alerts.po_approval_needed.message',
'message_params': params
}
@staticmethod
def _production_batch_start(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Production batch ready to start"""
metadata = enriched.alert_metadata
batch_number = metadata.get('batch_number', 'BATCH-XXXX')
product_name = metadata.get('product_name', 'Producto')
quantity_planned = metadata.get('quantity_planned', 0)
unit = metadata.get('unit', 'kg')
priority = metadata.get('priority', 'normal')
params = {
'batch_number': batch_number,
'product_name': product_name,
'quantity_planned': round(quantity_planned, 1),
'unit': unit,
'priority': priority
}
return {
'title_key': 'alerts.production_batch_start.title',
'title_params': {'product_name': product_name},
'message_key': 'alerts.production_batch_start.message',
'message_params': params
}
# ===================================================================
# ENVIRONMENTAL ALERTS
# ===================================================================
@staticmethod
def _temperature_breach(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Temperature breach alert"""
metadata = enriched.alert_metadata
location = metadata.get('location', 'Ubicación')
temperature = round(metadata.get('temperature', 0), 1)
duration = metadata.get('duration', 0)
return {
'title_key': 'alerts.temperature_breach.title',
'title_params': {'location': location},
'message_key': 'alerts.temperature_breach.message',
'message_params': {
'location': location,
'temperature': temperature,
'duration': duration
}
}
# ===================================================================
# FORECASTING ALERTS
# ===================================================================
@staticmethod
def _demand_surge(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Weekend demand surge"""
metadata = enriched.alert_metadata
urgency = enriched.urgency_context
product_name = metadata.get('product_name', 'Producto')
percentage = round(metadata.get('percentage', metadata.get('growth_percentage', 0)), 0)
predicted_demand = metadata.get('predicted_demand', 0)
current_stock = metadata.get('current_stock', 0)
params = {
'product_name': product_name,
'percentage': percentage
}
if predicted_demand and current_stock:
params['predicted_demand'] = round(predicted_demand, 0)
params['current_stock'] = round(current_stock, 0)
if urgency and urgency.time_until_consequence_hours:
params['hours_until'] = round(urgency.time_until_consequence_hours, 1)
return {
'title_key': 'alerts.demand_surge.title',
'title_params': {'product_name': product_name},
'message_key': 'alerts.demand_surge.message',
'message_params': params
}
@staticmethod
def _weather_impact(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Weather impact on demand"""
metadata = enriched.alert_metadata
weather_type = metadata.get('weather_type', 'Lluvia')
impact_percentage = round(metadata.get('impact_percentage', -20), 0)
return {
'title_key': 'alerts.weather_impact.title',
'title_params': {},
'message_key': 'alerts.weather_impact.message',
'message_params': {
'weather_type': weather_type,
'impact_percentage': abs(impact_percentage),
'is_negative': impact_percentage < 0
}
}
@staticmethod
def _holiday_prep(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Holiday preparation"""
metadata = enriched.alert_metadata
holiday_name = metadata.get('holiday_name', 'Festividad')
days = metadata.get('days', 0)
percentage = round(metadata.get('percentage', metadata.get('increase_percentage', 0)), 0)
return {
'title_key': 'alerts.holiday_prep.title',
'title_params': {'holiday_name': holiday_name},
'message_key': 'alerts.holiday_prep.message',
'message_params': {
'holiday_name': holiday_name,
'days': days,
'percentage': percentage
}
}
@staticmethod
def _severe_weather(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Severe weather impact"""
metadata = enriched.alert_metadata
weather_type = metadata.get('weather_type', 'Tormenta')
duration_hours = metadata.get('duration_hours', 0)
return {
'title_key': 'alerts.severe_weather.title',
'title_params': {'weather_type': weather_type},
'message_key': 'alerts.severe_weather.message',
'message_params': {
'weather_type': weather_type,
'duration_hours': duration_hours
}
}
@staticmethod
def _demand_spike(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Unexpected demand spike"""
metadata = enriched.alert_metadata
trend = enriched.trend_context
product_name = metadata.get('product_name', 'Producto')
spike_percentage = round(metadata.get('spike_percentage', metadata.get('growth_percentage', 0)), 0)
params = {
'product_name': product_name,
'spike_percentage': spike_percentage
}
if trend:
params['current_value'] = round(trend.current_value, 0)
params['baseline_value'] = round(trend.baseline_value, 0)
return {
'title_key': 'alerts.demand_spike.title',
'title_params': {'product_name': product_name},
'message_key': 'alerts.demand_spike.message',
'message_params': params
}
@staticmethod
def _demand_pattern(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Demand pattern optimization"""
metadata = enriched.alert_metadata
trend = enriched.trend_context
product_name = metadata.get('product_name', 'Producto')
variation = round(metadata.get('variation_percent', 0), 0)
params = {
'product_name': product_name,
'variation_percent': variation
}
if trend and trend.possible_causes:
params['possible_causes'] = ', '.join(trend.possible_causes[:2])
return {
'title_key': 'alerts.demand_pattern.title',
'title_params': {'product_name': product_name},
'message_key': 'alerts.demand_pattern.message',
'message_params': params
}
# ===================================================================
# RECOMMENDATIONS
# ===================================================================
@staticmethod
def _inventory_optimization(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Inventory optimization recommendation"""
metadata = enriched.alert_metadata
ingredient_name = metadata.get('ingredient_name', 'Ingrediente')
period = metadata.get('period', 7)
suggested_increase = round(metadata.get('suggested_increase', 0), 1)
return {
'title_key': 'recommendations.inventory_optimization.title',
'title_params': {'ingredient_name': ingredient_name},
'message_key': 'recommendations.inventory_optimization.message',
'message_params': {
'ingredient_name': ingredient_name,
'period': period,
'suggested_increase': suggested_increase
}
}
@staticmethod
def _production_efficiency(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Production efficiency recommendation"""
metadata = enriched.alert_metadata
suggested_time = metadata.get('suggested_time', '')
savings_percent = round(metadata.get('savings_percent', 0), 1)
return {
'title_key': 'recommendations.production_efficiency.title',
'title_params': {},
'message_key': 'recommendations.production_efficiency.message',
'message_params': {
'suggested_time': suggested_time,
'savings_percent': savings_percent
}
}
@staticmethod
def _sales_opportunity(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Sales opportunity recommendation"""
metadata = enriched.alert_metadata
product_name = metadata.get('product_name', 'Producto')
days = metadata.get('days', '')
increase_percent = round(metadata.get('increase_percent', 0), 0)
return {
'title_key': 'recommendations.sales_opportunity.title',
'title_params': {'product_name': product_name},
'message_key': 'recommendations.sales_opportunity.message',
'message_params': {
'product_name': product_name,
'days': days,
'increase_percent': increase_percent
}
}
@staticmethod
def _seasonal_adjustment(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Seasonal adjustment recommendation"""
metadata = enriched.alert_metadata
season = metadata.get('season', 'temporada')
products = metadata.get('products', 'productos estacionales')
return {
'title_key': 'recommendations.seasonal_adjustment.title',
'title_params': {},
'message_key': 'recommendations.seasonal_adjustment.message',
'message_params': {
'season': season,
'products': products
}
}
@staticmethod
def _cost_reduction(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Cost reduction recommendation"""
metadata = enriched.alert_metadata
supplier_name = metadata.get('supplier_name', 'Proveedor')
ingredient = metadata.get('ingredient', 'ingrediente')
savings_euros = round(metadata.get('savings_euros', 0), 0)
return {
'title_key': 'recommendations.cost_reduction.title',
'title_params': {},
'message_key': 'recommendations.cost_reduction.message',
'message_params': {
'supplier_name': supplier_name,
'ingredient': ingredient,
'savings_euros': savings_euros
}
}
@staticmethod
def _waste_reduction(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Waste reduction recommendation"""
metadata = enriched.alert_metadata
product = metadata.get('product', 'producto')
waste_reduction_percent = round(metadata.get('waste_reduction_percent', 0), 0)
return {
'title_key': 'recommendations.waste_reduction.title',
'title_params': {},
'message_key': 'recommendations.waste_reduction.message',
'message_params': {
'product': product,
'waste_reduction_percent': waste_reduction_percent
}
}
@staticmethod
def _quality_improvement(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Quality improvement recommendation"""
metadata = enriched.alert_metadata
product = metadata.get('product', 'producto')
return {
'title_key': 'recommendations.quality_improvement.title',
'title_params': {},
'message_key': 'recommendations.quality_improvement.message',
'message_params': {
'product': product
}
}
@staticmethod
def _customer_satisfaction(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Customer satisfaction recommendation"""
metadata = enriched.alert_metadata
product = metadata.get('product', 'producto')
days = metadata.get('days', '')
return {
'title_key': 'recommendations.customer_satisfaction.title',
'title_params': {},
'message_key': 'recommendations.customer_satisfaction.message',
'message_params': {
'product': product,
'days': days
}
}
@staticmethod
def _energy_optimization(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Energy optimization recommendation"""
metadata = enriched.alert_metadata
start_time = metadata.get('start_time', '')
end_time = metadata.get('end_time', '')
savings_euros = round(metadata.get('savings_euros', 0), 0)
return {
'title_key': 'recommendations.energy_optimization.title',
'title_params': {},
'message_key': 'recommendations.energy_optimization.message',
'message_params': {
'start_time': start_time,
'end_time': end_time,
'savings_euros': savings_euros
}
}
@staticmethod
def _staff_optimization(enriched: EnrichedAlert) -> Dict[str, Any]:
"""Staff optimization recommendation"""
metadata = enriched.alert_metadata
days = metadata.get('days', '')
hours = metadata.get('hours', '')
return {
'title_key': 'recommendations.staff_optimization.title',
'title_params': {},
'message_key': 'recommendations.staff_optimization.message',
'message_params': {
'days': days,
'hours': hours
}
}
def generate_contextual_message(enriched: EnrichedAlert) -> Dict[str, Any]:
"""
Main entry point for contextual message generation with i18n support
Args:
enriched: Fully enriched alert with all context
Returns:
Dict with:
- title_key: i18n translation key for title
- title_params: parameters for title translation
- message_key: i18n translation key for message
- message_params: parameters for message translation
- fallback_title: fallback if i18n not available
- fallback_message: fallback if i18n not available
"""
return ContextualMessageGenerator.generate_message_data(enriched)

View File

@@ -1,276 +0,0 @@
# shared/alerts/templates.py
"""
Alert and recommendation templates in Spanish for the bakery platform
"""
from typing import Dict, Any
ITEM_TEMPLATES = {
# ALERTS - Critical Issues Requiring Immediate Action
'critical_stock_shortage': {
'es': {
'title': '🚨 Stock Crítico: {ingredient_name}',
'message': 'Solo {current_stock}kg disponibles, necesarios {required_stock}kg para producción de mañana. Acción inmediata requerida.',
'actions': ['Realizar pedido de emergencia', 'Contactar proveedor', 'Ajustar plan de producción']
},
'en': {
'title': '🚨 Critical Stock: {ingredient_name}',
'message': 'Only {current_stock}kg available, {required_stock}kg needed for tomorrow\'s production. Immediate action required.',
'actions': ['Place emergency order', 'Contact supplier', 'Adjust production plan']
}
},
'temperature_breach': {
'es': {
'title': '🌡️ ALERTA TEMPERATURA',
'message': '{location}: {temperature}°C durante {duration} minutos. Revisar productos inmediatamente para evitar deterioro.',
'actions': ['Verificar productos', 'Llamar técnico refrigeración', 'Documentar incidencia', 'Mover productos']
},
'en': {
'title': '🌡️ TEMPERATURE ALERT',
'message': '{location}: {temperature}°C for {duration} minutes. Check products immediately to prevent spoilage.',
'actions': ['Check products', 'Call refrigeration technician', 'Document incident', 'Move products']
}
},
'production_delay': {
'es': {
'title': '⏰ Retraso en Producción',
'message': 'Lote {batch_name} con {delay_minutes} minutos de retraso. Impacto en entregas del día.',
'actions': ['Acelerar producción', 'Notificar clientes', 'Reorganizar horarios', 'Buscar capacidad adicional']
}
},
'expired_products': {
'es': {
'title': '📅 Productos Caducados',
'message': '{product_count} productos han caducado hoy. Retirar inmediatamente por seguridad alimentaria.',
'actions': ['Retirar productos', 'Revisar inventario', 'Ajustar pedidos', 'Documentar pérdidas']
}
},
'equipment_failure': {
'es': {
'title': '⚙️ Fallo de Equipo',
'message': '{equipment_name} no está funcionando correctamente. Producción afectada.',
'actions': ['Parar producción', 'Llamar mantenimiento', 'Usar equipo alternativo', 'Documentar fallo']
}
},
'maintenance_required': {
'es': {
'title': '🔧 Mantenimiento Requerido: {equipment_name}',
'message': 'Equipo {equipment_name} requiere mantenimiento en {days_until_maintenance} días.',
'actions': ['Programar mantenimiento', 'Revisar historial', 'Preparar repuestos', 'Planificar parada']
}
},
'low_equipment_efficiency': {
'es': {
'title': '📉 Baja Eficiencia: {equipment_name}',
'message': 'Eficiencia del {equipment_name} bajó a {efficiency_percent}%. Revisar funcionamiento.',
'actions': ['Revisar configuración', 'Limpiar equipo', 'Calibrar sensores', 'Revisar mantenimiento']
}
},
'order_overload': {
'es': {
'title': '📋 Sobrecarga de Pedidos',
'message': 'Capacidad excedida en {percentage}%. Riesgo de no cumplir entregas.',
'actions': ['Priorizar pedidos', 'Aumentar turnos', 'Rechazar nuevos pedidos', 'Buscar ayuda externa']
}
},
'supplier_delay': {
'es': {
'title': '🚚 Retraso de Proveedor',
'message': 'Entrega de {supplier_name} retrasada {hours} horas. Impacto en producción de {products}.',
'actions': ['Contactar proveedor', 'Buscar alternativas', 'Ajustar producción', 'Usar stock reserva']
}
},
# RECOMMENDATIONS - Proactive Suggestions for Optimization
'inventory_optimization': {
'es': {
'title': '📈 Optimización de Stock: {ingredient_name}',
'message': 'Basado en tendencias de {period} días, sugerimos aumentar stock mínimo en {suggested_increase}kg para reducir costos.',
'actions': ['Revisar niveles mínimos', 'Analizar proveedores', 'Actualizar configuración', 'Programar pedido mayor']
},
'en': {
'title': '📈 Stock Optimization: {ingredient_name}',
'message': 'Based on {period} day trends, suggest increasing minimum stock by {suggested_increase}kg to reduce costs.',
'actions': ['Review minimum levels', 'Analyze suppliers', 'Update configuration', 'Schedule larger order']
}
},
'production_efficiency': {
'es': {
'title': '⚙️ Mejora de Eficiencia',
'message': 'Cambiar horarios de horneado a {suggested_time} puede reducir costos energéticos en {savings_percent}%.',
'actions': ['Revisar horarios', 'Consultar personal', 'Probar nuevo horario', 'Medir resultados']
}
},
'sales_opportunity': {
'es': {
'title': '💰 Oportunidad de Venta',
'message': '{product_name} tiene alta demanda los {days}. Incrementar producción puede aumentar ventas {increase_percent}%.',
'actions': ['Aumentar producción', 'Promocionar producto', 'Revisar precios', 'Planificar ingredientes']
}
},
'seasonal_adjustment': {
'es': {
'title': '🍂 Ajuste Estacional',
'message': 'Época de {season}: ajustar producción de {products} según patrones históricos.',
'actions': ['Revisar recetas estacionales', 'Ajustar inventario', 'Planificar promociones', 'Entrenar personal']
}
},
'cost_reduction': {
'es': {
'title': '💡 Reducción de Costos',
'message': 'Cambiar a proveedor {supplier_name} para {ingredient} puede ahorrar {savings_euros}€/mes.',
'actions': ['Evaluar calidad', 'Negociar precios', 'Probar muestras', 'Cambiar proveedor gradualmente']
}
},
'waste_reduction': {
'es': {
'title': '♻️ Reducción de Desperdicio',
'message': 'Ajustar tamaños de lote de {product} puede reducir desperdicio en {waste_reduction_percent}%.',
'actions': ['Analizar ventas', 'Ajustar recetas', 'Cambiar lotes', 'Monitorear resultados']
}
},
'quality_improvement': {
'es': {
'title': '⭐ Mejora de Calidad',
'message': 'Temperatura de horneado de {product} puede optimizarse para mejor textura y sabor.',
'actions': ['Probar temperaturas', 'Documentar cambios', 'Entrenar panaderos', 'Obtener feedback']
}
},
'customer_satisfaction': {
'es': {
'title': '😊 Satisfacción del Cliente',
'message': 'Clientes solicitan más {product} los {days}. Considerar aumentar disponibilidad.',
'actions': ['Revisar comentarios', 'Aumentar producción', 'Crear promociones', 'Mejorar exhibición']
}
},
'energy_optimization': {
'es': {
'title': '⚡ Optimización Energética',
'message': 'Consolidar horneado entre {start_time} y {end_time} puede reducir costos energéticos {savings_euros}€/día.',
'actions': ['Revisar horarios energía', 'Reorganizar producción', 'Optimizar hornos', 'Medir consumo']
}
},
'staff_optimization': {
'es': {
'title': '👥 Optimización de Personal',
'message': 'Picos de trabajo los {days} a las {hours}. Considerar ajustar turnos para mejor eficiencia.',
'actions': ['Analizar cargas trabajo', 'Reorganizar turnos', 'Entrenar polivalencia', 'Contratar temporal']
}
},
# FORECASTING ALERTS - Demand prediction and planning alerts
'demand_surge_weekend': {
'es': {
'title': '📈 Fin de semana con alta demanda: {product_name}',
'message': '📈 Fin de semana con alta demanda: {product_name} +{percentage}%',
'actions': ['Aumentar producción', 'Pedir ingredientes extra', 'Programar personal']
}
},
'weather_impact_alert': {
'es': {
'title': '🌧️ Impacto climático previsto',
'message': '🌧️ Lluvia prevista: -20% tráfico peatonal esperado',
'actions': ['Reducir producción fresca', 'Enfoque productos comfort', 'Promoción delivery']
}
},
'holiday_preparation': {
'es': {
'title': '🎉 {holiday_name} en {days} días',
'message': '🎉 {holiday_name} en {days} días: pedidos especiales aumentan {percentage}%',
'actions': ['Preparar menú especial', 'Stock decoraciones', 'Extender horarios']
}
},
'demand_pattern_optimization': {
'es': {
'title': '📊 Optimización de Patrones: {product_name}',
'message': 'Demanda de {product_name} varía {variation_percent}% durante la semana. Oportunidad de optimización.',
'actions': ['Analizar patrones semanales', 'Ajustar producción diaria', 'Optimizar inventario', 'Planificar promociones']
}
},
'severe_weather_impact': {
'es': {
'title': '⛈️ Impacto Climático Severo',
'message': 'Tormenta severa prevista: reducir producción de productos frescos y activar delivery.',
'actions': ['Reducir producción fresca', 'Activar delivery', 'Asegurar displays exteriores']
}
},
'unexpected_demand_spike': {
'es': {
'title': '📈 Pico de Demanda Inesperado',
'message': 'Ventas de {product_name} {spike_percentage}% sobre pronóstico.',
'actions': ['Aumentar producción', 'Revisar inventario', 'Actualizar pronóstico']
}
}
}
def format_item_message(template_key: str, language: str, **kwargs) -> Dict[str, Any]:
"""Format item message using template with validation"""
template = ITEM_TEMPLATES.get(template_key, {}).get(language, {})
if not template:
# Fallback for missing templates
return {
'title': f'Notificación: {template_key}',
'message': f'Información: {", ".join([f"{k}: {v}" for k, v in kwargs.items()])}',
'actions': ['Revisar', 'Documentar']
}
try:
# Format with provided kwargs, handling missing values gracefully
formatted_title = template['title'].format(**kwargs)
formatted_message = template['message'].format(**kwargs)
return {
'title': formatted_title,
'message': formatted_message,
'actions': template.get('actions', [])
}
except KeyError as e:
# Handle missing format parameters
return {
'title': template.get('title', f'Notificación: {template_key}'),
'message': f"Error en plantilla - parámetro faltante: {e}. Datos: {kwargs}",
'actions': template.get('actions', ['Revisar configuración'])
}
def get_severity_emoji(severity: str) -> str:
"""Get emoji for severity level"""
emoji_map = {
'urgent': '🚨',
'high': '⚠️',
'medium': '💡',
'low': ''
}
return emoji_map.get(severity, '📋')
def get_item_type_emoji(item_type: str) -> str:
"""Get emoji for item type"""
emoji_map = {
'alert': '🚨',
'recommendation': '💡'
}
return emoji_map.get(item_type, '📋')
def format_business_time(hour: int) -> str:
"""Format hour in Spanish business context"""
if hour == 0:
return "medianoche"
elif hour < 12:
return f"{hour}:00 AM"
elif hour == 12:
return "12:00 PM (mediodía)"
else:
return f"{hour-12}:00 PM"
def get_spanish_day_name(day_number: int) -> str:
"""Get Spanish day name (0=Monday)"""
days = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
return days[day_number] if 0 <= day_number <= 6 else "día desconocido"
def format_currency(amount: float) -> str:
"""Format currency in Spanish Euro format"""
return f"{amount:.2f}"
def format_percentage(value: float) -> str:
"""Format percentage in Spanish format"""
return f"{value:.1f}%"

View File

@@ -94,6 +94,54 @@ class AlertsServiceClient(BaseServiceClient):
logger.error("Error fetching critical alerts", error=str(e), tenant_id=tenant_id)
return None
async def get_alerts(
self,
tenant_id: str,
priority_level: Optional[str] = None,
status: Optional[str] = None,
resolved: Optional[bool] = None,
limit: int = 100,
offset: int = 0
) -> Optional[Dict[str, Any]]:
"""
Get alerts with optional filters
Args:
tenant_id: Tenant ID
priority_level: Filter by priority level (critical, important, standard, info)
status: Filter by status (active, resolved, acknowledged, ignored)
resolved: Filter by resolved status (None = all, True = resolved only, False = unresolved only)
limit: Maximum number of alerts
offset: Pagination offset
Returns:
Dict with:
{
"alerts": [...],
"total": int,
"limit": int,
"offset": int
}
"""
try:
params = {"limit": limit, "offset": offset}
if priority_level:
params["priority_level"] = priority_level
if status:
params["status"] = status
if resolved is not None:
params["resolved"] = resolved
return await self.get(
"/alerts",
tenant_id=tenant_id,
params=params
)
except Exception as e:
logger.error("Error fetching alerts",
error=str(e), tenant_id=tenant_id)
return None
async def get_alerts_by_severity(
self,
tenant_id: str,
@@ -153,6 +201,44 @@ class AlertsServiceClient(BaseServiceClient):
alert_id=alert_id, tenant_id=tenant_id)
return None
async def get_dashboard_analytics(
self,
tenant_id: str,
days: int = 7
) -> Optional[Dict[str, Any]]:
"""
Get dashboard analytics including prevented issues and estimated savings
Args:
tenant_id: Tenant ID
days: Number of days to analyze (default: 7)
Returns:
Dict with analytics data:
{
"period_days": int,
"total_alerts": int,
"active_alerts": int,
"ai_handling_rate": float,
"prevented_issues_count": int,
"estimated_savings_eur": float,
"total_financial_impact_at_risk_eur": float,
"priority_distribution": {...},
"type_class_distribution": {...},
"active_by_type_class": {...},
"period_comparison": {...}
}
"""
try:
return await self.get(
"/alerts/analytics/dashboard",
tenant_id=tenant_id,
params={"days": days}
)
except Exception as e:
logger.error("Error fetching dashboard analytics", error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================

View File

@@ -1,11 +1,28 @@
# shared/config/rabbitmq_config.py
"""
RabbitMQ configuration for the alert and recommendation system
Supports both alerts and recommendations through a unified topic exchange
RabbitMQ configuration for the event system
Supports three event classes through a unified topic exchange:
- ALERT: Actionable events requiring user decision
- NOTIFICATION: Informational state changes
- RECOMMENDATION: AI-generated suggestions
Routing key pattern: {event_class}.{event_domain}.{severity}
Examples:
- alert.inventory.urgent
- notification.production.info
- recommendation.demand.medium
"""
RABBITMQ_CONFIG = {
"exchanges": {
"events": {
"name": "events.exchange",
"type": "topic",
"durable": True,
"auto_delete": False
},
# Legacy exchange for backward compatibility during migration
"alerts": {
"name": "alerts.exchange",
"type": "topic",
@@ -20,19 +37,31 @@ RABBITMQ_CONFIG = {
}
},
"queues": {
"alert_processing": {
"name": "alert.processing.queue",
"event_processing": {
"name": "event.processing.queue",
"durable": True,
"arguments": {
"x-message-ttl": 3600000, # 1 hour TTL
"x-max-length": 10000, # Max 10k messages
"x-overflow": "reject-publish",
"x-overflow": "reject-publish",
"x-dead-letter-exchange": "dlx.exchange",
"x-dead-letter-routing-key": "failed.events"
}
},
# Legacy queue for backward compatibility
"alert_processing": {
"name": "alert.processing.queue",
"durable": True,
"arguments": {
"x-message-ttl": 3600000,
"x-max-length": 10000,
"x-overflow": "reject-publish",
"x-dead-letter-exchange": "dlx.exchange",
"x-dead-letter-routing-key": "failed.items"
}
},
"dead_letter": {
"name": "alert.dead_letter.queue",
"name": "event.dead_letter.queue",
"durable": True,
"arguments": {
"x-message-ttl": 86400000 # 24 hours for dead letters
@@ -40,43 +69,148 @@ RABBITMQ_CONFIG = {
}
},
"bindings": [
# New event architecture bindings
{
"queue": "event.processing.queue",
"exchange": "events.exchange",
"routing_key": "*.*.*" # event_class.event_domain.severity
},
# Legacy bindings for backward compatibility
{
"queue": "alert.processing.queue",
"exchange": "alerts.exchange",
"routing_key": "*.*.*" # alert/recommendation.severity.service
},
{
"queue": "alert.dead_letter.queue",
"queue": "event.dead_letter.queue",
"exchange": "dlx.exchange",
"routing_key": "failed.items"
"routing_key": "failed.events"
},
{
"queue": "event.dead_letter.queue",
"exchange": "dlx.exchange",
"routing_key": "failed.items" # Legacy
}
],
"routing_patterns": {
# alert/recommendation.severity.service_name
"alert": "alert.{severity}.{service}",
"recommendation": "recommendation.{severity}.{service}",
# New event architecture patterns
# event_class.event_domain.severity
"alert_inventory": "alert.inventory.*",
"alert_production": "alert.production.*",
"alert_supply_chain": "alert.supply_chain.*",
"notification_inventory": "notification.inventory.*",
"notification_production": "notification.production.*",
"notification_operations": "notification.operations.*",
"recommendation_all": "recommendation.*.*",
# By severity
"all_urgent": "*.*.urgent",
"all_high": "*.*.high",
"all_medium": "*.*.medium",
"all_low": "*.*.low",
# By event class
"all_alerts": "alert.*.*",
"all_notifications": "notification.*.*",
"all_recommendations": "recommendation.*.*",
"urgent_items": "*.urgent.*",
"high_items": "*.high.*"
# By domain
"inventory_all": "*.inventory.*",
"production_all": "*.production.*",
"supply_chain_all": "*.supply_chain.*",
"demand_all": "*.demand.*",
"operations_all": "*.operations.*",
# Legacy patterns (for backward compatibility)
"legacy_alert": "alert.{severity}.{service}",
"legacy_recommendation": "recommendation.{severity}.{service}",
}
}
def get_routing_key(item_type: str, severity: str, service: str) -> str:
"""Generate routing key for item publishing"""
def get_routing_key(event_class: str, event_domain: str, severity: str) -> str:
"""
Generate routing key for event publishing.
New pattern: {event_class}.{event_domain}.{severity}
Args:
event_class: 'alert', 'notification', or 'recommendation'
event_domain: 'inventory', 'production', 'supply_chain', 'demand', 'operations'
severity: 'urgent', 'high', 'medium', 'low'
Returns:
Routing key string
Examples:
>>> get_routing_key('alert', 'inventory', 'urgent')
'alert.inventory.urgent'
>>> get_routing_key('notification', 'production', 'info')
'notification.production.info'
"""
return f"{event_class}.{event_domain}.{severity}"
def get_legacy_routing_key(item_type: str, severity: str, service: str) -> str:
"""
Generate legacy routing key for backward compatibility.
Legacy pattern: {item_type}.{severity}.{service}
TODO: Remove after migration is complete.
"""
return f"{item_type}.{severity}.{service}"
def get_binding_patterns(item_types: list = None, severities: list = None, services: list = None) -> list:
"""Generate binding patterns for selective consumption"""
def get_binding_patterns(
event_classes: list = None,
event_domains: list = None,
severities: list = None
) -> list:
"""
Generate binding patterns for selective consumption.
Args:
event_classes: List of event classes to bind (default: all)
event_domains: List of event domains to bind (default: all)
severities: List of severities to bind (default: all)
Returns:
List of routing key patterns
Examples:
>>> get_binding_patterns(['alert'], ['inventory'], ['urgent', 'high'])
['alert.inventory.urgent', 'alert.inventory.high']
"""
patterns = []
item_types = item_types or ["alert", "recommendation"]
event_classes = event_classes or ["alert", "notification", "recommendation"]
event_domains = event_domains or ["inventory", "production", "supply_chain", "demand", "operations"]
severities = severities or ["urgent", "high", "medium", "low"]
services = services or ["*"]
for item_type in item_types:
for severity in severities:
for service in services:
patterns.append(f"{item_type}.{severity}.{service}")
return patterns
for event_class in event_classes:
for event_domain in event_domains:
for severity in severities:
patterns.append(f"{event_class}.{event_domain}.{severity}")
return patterns
def priority_score_to_severity(priority_score: int) -> str:
"""
Convert priority score (0-100) to severity level.
Args:
priority_score: Priority score (0-100)
Returns:
Severity level: 'urgent', 'high', 'medium', or 'low'
"""
if priority_score >= 90:
return "urgent"
elif priority_score >= 70:
return "high"
elif priority_score >= 50:
return "medium"
else:
return "low"

View File

@@ -0,0 +1,276 @@
"""
Alert Types for Next-Generation Alert System
Defines enriched alert types that transform passive notifications into actionable guidance.
This replaces simple severity-based alerts with context-rich, prioritized, intelligent alerts.
"""
from enum import Enum
from typing import Dict, Any, Optional, List
from pydantic import BaseModel, Field
from datetime import datetime
# ============================================================
# Alert Type Classifications
# ============================================================
class AlertTypeClass(str, Enum):
"""High-level alert type classifications"""
ACTION_NEEDED = "action_needed" # Requires user decision
PREVENTED_ISSUE = "prevented_issue" # AI already handled, FYI
TREND_WARNING = "trend_warning" # Proactive insight
ESCALATION = "escalation" # Time-sensitive with auto-action countdown
INFORMATION = "information" # Pure informational
class PriorityLevel(str, Enum):
"""Priority levels based on multi-factor scoring"""
CRITICAL = "critical" # 90-100: Needs decision in next 2 hours
IMPORTANT = "important" # 70-89: Needs decision today
STANDARD = "standard" # 50-69: Review when convenient
INFO = "info" # 0-49: For awareness
class PlacementHint(str, Enum):
"""UI placement hints for where alert should appear"""
TOAST = "toast" # Immediate popup notification
ACTION_QUEUE = "action_queue" # Dashboard action queue section
DASHBOARD_INLINE = "dashboard_inline" # Embedded in relevant dashboard section
NOTIFICATION_PANEL = "notification_panel" # Bell icon notification panel
EMAIL_DIGEST = "email_digest" # End-of-day email summary
# ============================================================
# Smart Action Definitions
# ============================================================
class SmartActionType(str, Enum):
"""Types of smart actions users can take"""
APPROVE_PO = "approve_po"
REJECT_PO = "reject_po"
MODIFY_PO = "modify_po"
CALL_SUPPLIER = "call_supplier"
NAVIGATE = "navigate"
ADJUST_PRODUCTION = "adjust_production"
START_PRODUCTION_BATCH = "start_production_batch"
NOTIFY_CUSTOMER = "notify_customer"
CANCEL_AUTO_ACTION = "cancel_auto_action"
MARK_DELIVERY_RECEIVED = "mark_delivery_received"
COMPLETE_STOCK_RECEIPT = "complete_stock_receipt"
OPEN_REASONING = "open_reasoning"
SNOOZE = "snooze"
DISMISS = "dismiss"
MARK_READ = "mark_read"
class SmartAction(BaseModel):
"""Smart action button definition"""
label: str = Field(..., description="User-facing button label")
type: SmartActionType = Field(..., description="Action type for handler routing")
variant: str = Field(default="primary", description="UI variant: primary, secondary, tertiary, danger")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Action-specific data")
disabled: bool = Field(default=False, description="Whether action is disabled")
disabled_reason: Optional[str] = Field(None, description="Reason why action is disabled")
estimated_time_minutes: Optional[int] = Field(None, description="Estimated time to complete action")
consequence: Optional[str] = Field(None, description="What happens if this action is taken")
# ============================================================
# Context & Enrichment Models
# ============================================================
class OrchestratorContext(BaseModel):
"""Context from Daily Orchestrator about recent actions"""
already_addressed: bool = Field(..., description="Has AI already addressed this issue?")
action_type: Optional[str] = Field(None, description="Type of action taken: PO, batch, adjustment")
action_id: Optional[str] = Field(None, description="ID of the PO/batch created")
action_status: Optional[str] = Field(None, description="Status: created, pending_approval, completed")
delivery_date: Optional[datetime] = Field(None, description="When will solution arrive")
reasoning: Optional[Dict[str, Any]] = Field(None, description="Structured reasoning data")
estimated_resolution_time: Optional[datetime] = Field(None, description="When issue will be resolved")
estimated_savings_eur: Optional[float] = Field(None, description="Estimated savings from preventing this issue")
class BusinessImpact(BaseModel):
"""Business impact assessment"""
financial_impact_eur: Optional[float] = Field(None, description="Estimated € impact")
affected_orders: Optional[int] = Field(None, description="Number of orders affected")
affected_customers: Optional[List[str]] = Field(None, description="Customer names affected")
production_batches_at_risk: Optional[List[str]] = Field(None, description="Batch IDs at risk")
stockout_risk_hours: Optional[float] = Field(None, description="Hours until stockout")
waste_risk_kg: Optional[float] = Field(None, description="Kg of waste risk")
customer_satisfaction_impact: Optional[str] = Field(None, description="Impact level: high, medium, low")
class UrgencyContext(BaseModel):
"""Urgency and timing context"""
deadline: Optional[datetime] = Field(None, description="Hard deadline for decision")
time_until_consequence_hours: Optional[float] = Field(None, description="Hours until consequence occurs")
can_wait_until_tomorrow: bool = Field(default=True, description="Can this wait until tomorrow?")
peak_hour_relevant: bool = Field(default=False, description="Is this relevant during peak hours?")
auto_action_countdown_seconds: Optional[int] = Field(None, description="Seconds until auto-action triggers")
class UserAgency(BaseModel):
"""User's ability to act on this alert"""
can_user_fix: bool = Field(..., description="Can the user actually fix this?")
requires_external_party: bool = Field(default=False, description="Requires supplier/customer action?")
external_party_name: Optional[str] = Field(None, description="Name of external party")
external_party_contact: Optional[str] = Field(None, description="Phone/email of external party")
blockers: Optional[List[str]] = Field(None, description="Things blocking user from acting")
suggested_workaround: Optional[str] = Field(None, description="Alternative solution if blocked")
class TrendContext(BaseModel):
"""Trend analysis context"""
metric_name: str = Field(..., description="Name of metric trending")
current_value: float = Field(..., description="Current value")
baseline_value: float = Field(..., description="Baseline/expected value")
change_percentage: float = Field(..., description="Percentage change")
direction: str = Field(..., description="Direction: increasing, decreasing")
significance: str = Field(..., description="Significance: high, medium, low")
period_days: int = Field(..., description="Number of days in trend period")
possible_causes: Optional[List[str]] = Field(None, description="Potential root causes")
# ============================================================
# Enriched Alert Model
# ============================================================
class EnrichedAlert(BaseModel):
"""
Next-generation enriched alert with full context and guidance.
This is what gets sent to the frontend after intelligence processing.
"""
# Original Alert Data
id: str = Field(..., description="Alert UUID")
tenant_id: str = Field(..., description="Tenant UUID")
service: str = Field(..., description="Originating service")
alert_type: str = Field(..., description="Specific alert type code")
title: str = Field(..., description="User-facing title")
message: str = Field(..., description="Detailed message")
# Classification
type_class: AlertTypeClass = Field(..., description="High-level classification")
priority_level: PriorityLevel = Field(..., description="Priority level")
priority_score: int = Field(..., description="Numeric priority score 0-100")
# Context Enrichment
orchestrator_context: Optional[OrchestratorContext] = Field(None, description="AI system context")
business_impact: Optional[BusinessImpact] = Field(None, description="Business impact assessment")
urgency_context: Optional[UrgencyContext] = Field(None, description="Urgency and timing")
user_agency: Optional[UserAgency] = Field(None, description="User's ability to act")
trend_context: Optional[TrendContext] = Field(None, description="Trend analysis (if trend warning)")
# AI Reasoning
ai_reasoning_summary: Optional[str] = Field(None, description="Plain language AI reasoning")
reasoning_data: Optional[Dict[str, Any]] = Field(None, description="Structured reasoning from orchestrator")
confidence_score: Optional[float] = Field(None, description="AI confidence 0-1")
# Actions
actions: List[SmartAction] = Field(default_factory=list, description="Smart action buttons")
primary_action: Optional[SmartAction] = Field(None, description="Primary recommended action")
# UI Placement
placement: List[PlacementHint] = Field(default_factory=list, description="Where to show this alert")
# Grouping
group_id: Optional[str] = Field(None, description="Group ID if part of grouped alerts")
is_group_summary: bool = Field(default=False, description="Is this a group summary?")
grouped_alert_count: Optional[int] = Field(None, description="Number of alerts in group")
grouped_alert_ids: Optional[List[str]] = Field(None, description="IDs of grouped alerts")
# Metadata
created_at: datetime = Field(..., description="When alert was created")
enriched_at: datetime = Field(..., description="When alert was enriched")
alert_metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
# Status
status: str = Field(default="active", description="Status: active, resolved, acknowledged, snoozed")
# ============================================================
# Raw Alert Input Model
# ============================================================
class RawAlert(BaseModel):
"""
Raw alert from originating services (inventory, production, etc.)
This is what services send before enrichment.
"""
tenant_id: str
alert_type: str
title: str
message: str
service: str
actions: Optional[List[str]] = None # Simple action labels
alert_metadata: Dict[str, Any] = Field(default_factory=dict)
item_type: str = Field(default="alert") # alert or recommendation
# ============================================================
# Alert Group Model
# ============================================================
class AlertGroup(BaseModel):
"""Grouped alerts for better UX"""
group_id: str = Field(..., description="Group UUID")
tenant_id: str = Field(..., description="Tenant UUID")
group_type: str = Field(..., description="Type of grouping: supplier, service, type")
title: str = Field(..., description="Group title")
summary: str = Field(..., description="Group summary message")
alert_count: int = Field(..., description="Number of alerts in group")
alert_ids: List[str] = Field(..., description="Alert UUIDs in group")
highest_priority_score: int = Field(..., description="Highest priority in group")
created_at: datetime = Field(..., description="When group was created")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Group metadata")
# ============================================================
# Priority Scoring Components
# ============================================================
class PriorityScoreComponents(BaseModel):
"""Breakdown of priority score calculation"""
business_impact_score: float = Field(..., description="Business impact component 0-100")
urgency_score: float = Field(..., description="Urgency component 0-100")
user_agency_score: float = Field(..., description="User agency component 0-100")
confidence_score: float = Field(..., description="Confidence component 0-100")
final_score: int = Field(..., description="Final weighted score 0-100")
weights: Dict[str, float] = Field(..., description="Weights used in calculation")
# ============================================================
# Standard Alert Type Constants
# ============================================================
class AlertTypeConstants:
"""Standard alert type string constants"""
# Inventory alerts
LOW_STOCK_WARNING = "low_stock_warning"
CRITICAL_STOCK_SHORTAGE = "critical_stock_shortage"
EXPIRING_SOON = "expiring_soon"
EXPIRED_STOCK = "expired_stock"
# Production alerts
PRODUCTION_DELAY = "production_delay"
PRODUCTION_STALLED = "production_stalled"
BATCH_AT_RISK = "batch_at_risk"
PRODUCTION_BATCH_START = "production_batch_start"
# Purchase Order alerts
PO_APPROVAL_NEEDED = "po_approval_needed"
PO_APPROVAL_ESCALATION = "po_approval_escalation"
# Delivery lifecycle alerts (NEW)
DELIVERY_SCHEDULED = "delivery_scheduled"
DELIVERY_ARRIVING_SOON = "delivery_arriving_soon"
DELIVERY_OVERDUE = "delivery_overdue"
STOCK_RECEIPT_INCOMPLETE = "stock_receipt_incomplete"
# Forecasting alerts
DEMAND_SURGE_PREDICTED = "demand_surge_predicted"
DEMAND_DROP_PREDICTED = "demand_drop_predicted"

View File

@@ -0,0 +1,343 @@
"""
Event Classification Schema
This module defines the three-tier event model that separates:
- ALERTS: Actionable events requiring user decision
- NOTIFICATIONS: Informational state changes (FYI only)
- RECOMMENDATIONS: Advisory suggestions from AI
This replaces the old conflated "alert" system with semantic clarity.
"""
from enum import Enum
from typing import Any, Dict, List, Optional
from datetime import datetime
from pydantic import BaseModel, Field
class EventClass(str, Enum):
"""
Top-level event classification.
- ALERT: Actionable, requires user decision, has smart actions
- NOTIFICATION: Informational state change, no action needed
- RECOMMENDATION: Advisory suggestion, optional action
"""
ALERT = "alert"
NOTIFICATION = "notification"
RECOMMENDATION = "recommendation"
class EventDomain(str, Enum):
"""
Business domain classification for events.
Enables domain-specific dashboards and selective subscription.
"""
INVENTORY = "inventory"
PRODUCTION = "production"
SUPPLY_CHAIN = "supply_chain"
DEMAND = "demand"
OPERATIONS = "operations"
class PriorityLevel(str, Enum):
"""Priority levels for alerts and recommendations."""
CRITICAL = "critical" # 90-100: Immediate action required
IMPORTANT = "important" # 70-89: Action needed soon
STANDARD = "standard" # 50-69: Normal priority
INFO = "info" # 0-49: Low priority, informational
class AlertTypeClass(str, Enum):
"""
Alert-specific classification (only applies to EventClass.ALERT).
"""
ACTION_NEEDED = "action_needed" # User must decide
PREVENTED_ISSUE = "prevented_issue" # AI already handled, FYI
TREND_WARNING = "trend_warning" # Pattern detected
ESCALATION = "escalation" # Time-sensitive with auto-action countdown
INFORMATION = "information" # Pure informational alert
class NotificationType(str, Enum):
"""
Notification-specific types for state changes.
"""
STATE_CHANGE = "state_change" # Entity state transition
COMPLETION = "completion" # Process/task completed
ARRIVAL = "arrival" # Entity arrived/received
DEPARTURE = "departure" # Entity left/shipped
UPDATE = "update" # General update
SYSTEM_EVENT = "system_event" # System operation
class RecommendationType(str, Enum):
"""
Recommendation-specific types.
"""
OPTIMIZATION = "optimization" # Efficiency improvement
COST_REDUCTION = "cost_reduction" # Save money
RISK_MITIGATION = "risk_mitigation" # Prevent future issues
TREND_INSIGHT = "trend_insight" # Pattern analysis
BEST_PRACTICE = "best_practice" # Suggested approach
class RawEvent(BaseModel):
"""
Base event emitted by domain services.
This is the unified schema replacing the old RawAlert.
All domain services emit RawEvents which are then conditionally enriched.
"""
tenant_id: str = Field(..., description="Tenant identifier")
# Event classification
event_class: EventClass = Field(..., description="Alert, Notification, or Recommendation")
event_domain: EventDomain = Field(..., description="Business domain (inventory, production, etc.)")
event_type: str = Field(..., description="Specific event type (e.g., 'critical_stock_shortage')")
# Core content
title: str = Field(..., description="Event title")
message: str = Field(..., description="Event message")
# Source
service: str = Field(..., description="Originating service name")
# Actions (optional, mainly for alerts)
actions: Optional[List[str]] = Field(default=None, description="Available action types")
# Metadata (domain-specific data)
event_metadata: Dict[str, Any] = Field(default_factory=dict, description="Domain-specific metadata")
# Timestamp
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Event creation time")
# Deduplication (optional)
deduplication_key: Optional[str] = Field(default=None, description="Key for deduplication")
class Config:
use_enum_values = True
class EnrichedAlert(BaseModel):
"""
Fully enriched alert with priority scoring, smart actions, and context.
Only used for EventClass.ALERT.
"""
# From RawEvent
id: str
tenant_id: str
event_domain: EventDomain
event_type: str
title: str
message: str
service: str
timestamp: datetime
# Alert-specific
type_class: AlertTypeClass
status: str # active, acknowledged, resolved, dismissed
# Priority
priority_score: int = Field(..., ge=0, le=100, description="0-100 priority score")
priority_level: PriorityLevel
# Enrichment context
orchestrator_context: Optional[Dict[str, Any]] = Field(default=None)
business_impact: Optional[Dict[str, Any]] = Field(default=None)
urgency_context: Optional[Dict[str, Any]] = Field(default=None)
user_agency: Optional[Dict[str, Any]] = Field(default=None)
# Smart actions
smart_actions: Optional[List[Dict[str, Any]]] = Field(default=None)
# AI reasoning
ai_reasoning_summary: Optional[str] = Field(default=None)
confidence_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
# Timing
timing_decision: Optional[str] = Field(default=None)
scheduled_send_time: Optional[datetime] = Field(default=None)
placement: Optional[List[str]] = Field(default=None)
# Metadata
alert_metadata: Dict[str, Any] = Field(default_factory=dict)
class Config:
use_enum_values = True
class Notification(BaseModel):
"""
Lightweight notification for state changes.
Only used for EventClass.NOTIFICATION.
"""
# From RawEvent
id: str
tenant_id: str
event_domain: EventDomain
event_type: str
notification_type: NotificationType
title: str
message: str
service: str
timestamp: datetime
# Lightweight context
entity_type: Optional[str] = Field(default=None, description="Type of entity (batch, delivery, etc.)")
entity_id: Optional[str] = Field(default=None, description="ID of entity")
old_state: Optional[str] = Field(default=None, description="Previous state")
new_state: Optional[str] = Field(default=None, description="New state")
# Display metadata
notification_metadata: Dict[str, Any] = Field(default_factory=dict)
# Placement (lightweight, typically just toast + panel)
placement: List[str] = Field(default_factory=lambda: ["notification_panel"])
# TTL tracking
expires_at: Optional[datetime] = Field(default=None, description="Auto-delete after this time")
class Config:
use_enum_values = True
class Recommendation(BaseModel):
"""
AI-generated recommendation with moderate enrichment.
Only used for EventClass.RECOMMENDATION.
"""
# From RawEvent
id: str
tenant_id: str
event_domain: EventDomain
event_type: str
recommendation_type: RecommendationType
title: str
message: str
service: str
timestamp: datetime
# Recommendation-specific
priority_level: PriorityLevel = Field(default=PriorityLevel.INFO)
# Context (lighter than alerts, no orchestrator queries)
estimated_impact: Optional[Dict[str, Any]] = Field(default=None, description="Estimated benefit")
suggested_actions: Optional[List[Dict[str, Any]]] = Field(default=None)
# AI reasoning
ai_reasoning_summary: Optional[str] = Field(default=None)
confidence_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
# Dismissal tracking
dismissed_at: Optional[datetime] = Field(default=None)
dismissed_by: Optional[str] = Field(default=None)
# Metadata
recommendation_metadata: Dict[str, Any] = Field(default_factory=dict)
class Config:
use_enum_values = True
# Event type mappings for easy classification
EVENT_TYPE_TO_CLASS_MAP = {
# Alerts (actionable)
"critical_stock_shortage": (EventClass.ALERT, EventDomain.INVENTORY),
"production_delay": (EventClass.ALERT, EventDomain.PRODUCTION),
"equipment_failure": (EventClass.ALERT, EventDomain.PRODUCTION),
"po_approval_needed": (EventClass.ALERT, EventDomain.SUPPLY_CHAIN),
"delivery_overdue": (EventClass.ALERT, EventDomain.SUPPLY_CHAIN),
"temperature_breach": (EventClass.ALERT, EventDomain.INVENTORY),
"expired_products": (EventClass.ALERT, EventDomain.INVENTORY),
"low_stock_warning": (EventClass.ALERT, EventDomain.INVENTORY),
"production_ingredient_shortage": (EventClass.ALERT, EventDomain.INVENTORY),
"order_overload": (EventClass.ALERT, EventDomain.PRODUCTION),
# Notifications (informational)
"stock_received": (EventClass.NOTIFICATION, EventDomain.INVENTORY),
"stock_movement": (EventClass.NOTIFICATION, EventDomain.INVENTORY),
"batch_state_changed": (EventClass.NOTIFICATION, EventDomain.PRODUCTION),
"batch_completed": (EventClass.NOTIFICATION, EventDomain.PRODUCTION),
"orchestration_run_started": (EventClass.NOTIFICATION, EventDomain.OPERATIONS),
"orchestration_run_completed": (EventClass.NOTIFICATION, EventDomain.OPERATIONS),
"po_approved": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
"po_sent_to_supplier": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
"delivery_scheduled": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
"delivery_arriving_soon": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
"delivery_received": (EventClass.NOTIFICATION, EventDomain.SUPPLY_CHAIN),
# Recommendations (advisory)
"demand_surge_predicted": (EventClass.RECOMMENDATION, EventDomain.DEMAND),
"weather_impact_forecast": (EventClass.RECOMMENDATION, EventDomain.DEMAND),
"holiday_preparation": (EventClass.RECOMMENDATION, EventDomain.DEMAND),
"inventory_optimization_opportunity": (EventClass.RECOMMENDATION, EventDomain.INVENTORY),
"cost_reduction_suggestion": (EventClass.RECOMMENDATION, EventDomain.SUPPLY_CHAIN),
"efficiency_improvement": (EventClass.RECOMMENDATION, EventDomain.PRODUCTION),
}
def get_event_classification(event_type: str) -> tuple[EventClass, EventDomain]:
"""
Get the event_class and event_domain for a given event_type.
Args:
event_type: The specific event type string
Returns:
Tuple of (EventClass, EventDomain)
Raises:
ValueError: If event_type is not recognized
"""
if event_type in EVENT_TYPE_TO_CLASS_MAP:
return EVENT_TYPE_TO_CLASS_MAP[event_type]
# Default: treat unknown types as notifications in operations domain
return (EventClass.NOTIFICATION, EventDomain.OPERATIONS)
def get_redis_channel(tenant_id: str, event_domain: EventDomain, event_class: EventClass) -> str:
"""
Get the Redis pub/sub channel name for an event.
Pattern: tenant:{tenant_id}:{domain}.{class}
Examples:
- tenant:uuid:inventory.alerts
- tenant:uuid:production.notifications
- tenant:uuid:recommendations (recommendations not domain-specific)
Args:
tenant_id: Tenant identifier
event_domain: Event domain
event_class: Event class
Returns:
Redis channel name
"""
if event_class == EventClass.RECOMMENDATION:
# Recommendations go to a tenant-wide channel
return f"tenant:{tenant_id}:recommendations"
return f"tenant:{tenant_id}:{event_domain.value}.{event_class.value}s"
def get_rabbitmq_routing_key(event_class: EventClass, event_domain: EventDomain, severity: str) -> str:
"""
Get the RabbitMQ routing key for an event.
Pattern: {event_class}.{event_domain}.{severity}
Examples:
- alert.inventory.urgent
- notification.production.info
- recommendation.demand.medium
Args:
event_class: Event class
event_domain: Event domain
severity: Severity level (urgent, high, medium, low)
Returns:
RabbitMQ routing key
"""
return f"{event_class.value}.{event_domain.value}.{severity}"

View File

@@ -10,8 +10,8 @@ from typing import Optional
# Base reference date for all demo seed data
# All seed scripts should use this as the "logical seed date"
# Updated to November 2025 to show recent orchestration runs
BASE_REFERENCE_DATE = datetime(2025, 11, 25, 12, 0, 0, tzinfo=timezone.utc)
# IMPORTANT: Must match the actual dates in seed data (production batches start Jan 8, 2025)
BASE_REFERENCE_DATE = datetime(2025, 1, 8, 6, 0, 0, tzinfo=timezone.utc)
def adjust_date_for_demo(