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}%"