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

@@ -13,7 +13,6 @@ import structlog
from apscheduler.triggers.cron import CronTrigger
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
from shared.alerts.templates import format_item_message
logger = structlog.get_logger()
@@ -127,17 +126,13 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
percentage = issue['capacity_percentage']
if status == 'severe_overload':
template_data = self.format_spanish_message(
'order_overload',
percentage=int(percentage - 100)
)
await self.publish_item(tenant_id, {
'type': 'severe_capacity_overload',
'severity': 'urgent',
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'planned_date': issue['planned_date'].isoformat(),
'capacity_percentage': float(percentage),
@@ -228,20 +223,16 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
else:
severity = 'low'
template_data = self.format_spanish_message(
'production_delay',
batch_name=f"{delay['product_name']} #{delay['batch_number']}",
delay_minutes=int(delay_minutes)
)
await self.publish_item(delay['tenant_id'], {
'type': 'production_delay',
'severity': severity,
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'batch_id': str(delay['id']),
'batch_name': f"{delay['product_name']} #{delay['batch_number']}",
'product_name': delay['product_name'],
'batch_number': delay['batch_number'],
'delay_minutes': delay_minutes,
@@ -367,17 +358,13 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
days_to_maintenance = equipment.get('days_to_maintenance', 30)
if status == 'down':
template_data = self.format_spanish_message(
'equipment_failure',
equipment_name=equipment['name']
)
await self.publish_item(equipment['tenant_id'], {
'type': 'equipment_failure',
'severity': 'urgent',
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'equipment_id': str(equipment['id']),
'equipment_name': equipment['name'],
@@ -389,18 +376,13 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
elif status == 'maintenance' or (days_to_maintenance is not None and days_to_maintenance <= 3):
severity = 'high' if (days_to_maintenance is not None and days_to_maintenance <= 1) else 'medium'
template_data = self.format_spanish_message(
'maintenance_required',
equipment_name=equipment['name'],
days_until_maintenance=max(0, int(days_to_maintenance)) if days_to_maintenance is not None else 3
)
await self.publish_item(equipment['tenant_id'], {
'type': 'maintenance_required',
'severity': severity,
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'equipment_id': str(equipment['id']),
'equipment_name': equipment['name'],
@@ -412,18 +394,13 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
elif efficiency is not None and efficiency < 80:
severity = 'medium' if efficiency < 70 else 'low'
template_data = self.format_spanish_message(
'low_equipment_efficiency',
equipment_name=equipment['name'],
efficiency_percent=round(efficiency, 1)
)
await self.publish_item(equipment['tenant_id'], {
'type': 'low_equipment_efficiency',
'severity': severity,
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'equipment_id': str(equipment['id']),
'equipment_name': equipment['name'],
@@ -476,19 +453,15 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
efficiency_loss = rec['efficiency_loss_percent']
if rec_type == 'reduce_production_time':
template_data = self.format_spanish_message(
'production_efficiency',
suggested_time=f"{rec['start_hour']:02d}:00",
savings_percent=efficiency_loss
)
await self.publish_item(tenant_id, {
'type': 'production_efficiency',
'severity': 'medium',
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'suggested_time': f"{rec['start_hour']:02d}:00",
'product_name': rec['product_name'],
'avg_production_time': float(rec['avg_production_time']),
'avg_planned_duration': float(rec['avg_planned_duration']),
@@ -585,20 +558,17 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
peak_hour_record['avg_energy']) * 100
if potential_savings > 15: # More than 15% potential savings
template_data = self.format_spanish_message(
'energy_optimization',
start_time=f"{min_off_peak['hour_of_day']:02d}:00",
end_time=f"{min_off_peak['hour_of_day']+2:02d}:00",
savings_euros=potential_savings * 0.15 # Rough estimate
)
await self.publish_item(tenant_id, {
'type': 'energy_optimization',
'severity': 'low',
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'start_time': f"{min_off_peak['hour_of_day']:02d}:00",
'end_time': f"{min_off_peak['hour_of_day']+2:02d}:00",
'savings_euros': round(potential_savings * 0.15, 2),
'equipment_name': equipment,
'peak_hour': peak_hour_record['hour_of_day'],
'optimal_hour': min_off_peak['hour_of_day'],
@@ -629,20 +599,16 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
data = json.loads(payload)
tenant_id = UUID(data['tenant_id'])
template_data = self.format_spanish_message(
'production_delay',
batch_name=f"{data['product_name']} #{data.get('batch_number', 'N/A')}",
delay_minutes=data['delay_minutes']
)
await self.publish_item(tenant_id, {
'type': 'production_delay',
'severity': 'high',
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'batch_id': data['batch_id'],
'batch_name': f"{data['product_name']} #{data.get('batch_number', 'N/A')}",
'delay_minutes': data['delay_minutes'],
'trigger_source': 'database'
}
@@ -711,4 +677,84 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
logger.error("Error getting affected production batches",
ingredient_id=ingredient_id,
error=str(e))
return []
return []
async def emit_batch_start_alert(
self,
tenant_id: UUID,
batch_id: str,
batch_number: str,
product_name: str,
product_sku: str,
quantity_planned: float,
unit: str,
priority: str = "normal",
estimated_duration_minutes: Optional[int] = None,
scheduled_start_time: Optional[datetime] = None,
reasoning_data: Optional[Dict[str, Any]] = None
) -> None:
"""
Emit action_needed alert when a production batch is ready to start.
This appears in the Cola de Acciones (Action Queue) to prompt user to start the batch.
Args:
tenant_id: Tenant UUID
batch_id: Production batch UUID
batch_number: Human-readable batch number
product_name: Product name
product_sku: Product SKU
quantity_planned: Planned quantity
unit: Unit of measurement
priority: Batch priority (urgent, high, normal, low)
estimated_duration_minutes: Estimated production duration
scheduled_start_time: When batch is scheduled to start
reasoning_data: Structured reasoning from orchestrator (if auto-created)
"""
try:
# Determine severity based on priority and timing
if priority == 'urgent':
severity = 'urgent'
elif priority == 'high':
severity = 'high'
else:
severity = 'medium'
# Build alert metadata
metadata = {
'batch_id': str(batch_id),
'batch_number': batch_number,
'product_name': product_name,
'product_sku': product_sku,
'quantity_planned': float(quantity_planned),
'unit': unit,
'priority': priority,
'estimated_duration_minutes': estimated_duration_minutes,
'scheduled_start_time': scheduled_start_time.isoformat() if scheduled_start_time else None,
'reasoning_data': reasoning_data
}
await self.publish_item(tenant_id, {
'type': 'production_batch_start',
'type_class': 'action_needed',
'severity': severity,
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': ['start_production_batch', 'reschedule_batch', 'view_batch_details'],
'metadata': metadata
}, item_type='alert')
logger.info(
"Production batch start alert emitted",
batch_id=str(batch_id),
batch_number=batch_number,
product_name=product_name,
tenant_id=str(tenant_id)
)
except Exception as e:
logger.error(
"Failed to emit batch start alert",
batch_id=str(batch_id),
error=str(e),
exc_info=True
)

View File

@@ -0,0 +1,307 @@
"""
Production Notification Service
Emits informational notifications for production state changes:
- batch_state_changed: When batch transitions between states
- batch_completed: When batch production completes
- batch_started: When batch production begins
These are NOTIFICATIONS (not alerts) - informational state changes that don't require user action.
"""
import logging
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from shared.schemas.event_classification import RawEvent, EventClass, EventDomain
from shared.alerts.base_service import BaseAlertService
logger = logging.getLogger(__name__)
class ProductionNotificationService(BaseAlertService):
"""
Service for emitting production notifications (informational state changes).
"""
def __init__(self, rabbitmq_url: str = None):
super().__init__(service_name="production", rabbitmq_url=rabbitmq_url)
async def emit_batch_state_changed_notification(
self,
db: Session,
tenant_id: str,
batch_id: str,
product_sku: str,
product_name: str,
old_status: str,
new_status: str,
quantity: float,
unit: str,
assigned_to: Optional[str] = None,
) -> None:
"""
Emit notification when a production batch changes state.
Args:
db: Database session
tenant_id: Tenant ID
batch_id: Production batch ID
product_sku: Product SKU
product_name: Product name
old_status: Previous status (PENDING, IN_PROGRESS, COMPLETED, etc.)
new_status: New status
quantity: Batch quantity
unit: Unit of measurement
assigned_to: Assigned worker/station (optional)
"""
try:
# Build message based on state transition
transition_messages = {
("PENDING", "IN_PROGRESS"): f"Production started for {product_name}",
("IN_PROGRESS", "COMPLETED"): f"Production completed for {product_name}",
("IN_PROGRESS", "PAUSED"): f"Production paused for {product_name}",
("PAUSED", "IN_PROGRESS"): f"Production resumed for {product_name}",
("IN_PROGRESS", "FAILED"): f"Production failed for {product_name}",
}
message = transition_messages.get(
(old_status, new_status),
f"{product_name} status changed from {old_status} to {new_status}"
)
# Create notification event
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.PRODUCTION,
event_type="batch_state_changed",
title=f"Batch Status: {new_status}",
message=f"{message} ({quantity} {unit})",
service="production",
event_metadata={
"batch_id": batch_id,
"product_sku": product_sku,
"product_name": product_name,
"old_status": old_status,
"new_status": new_status,
"quantity": quantity,
"unit": unit,
"assigned_to": assigned_to,
"state_changed_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
# Publish to RabbitMQ for processing
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Batch state change notification emitted: {batch_id} ({old_status}{new_status})",
extra={"tenant_id": tenant_id, "batch_id": batch_id}
)
except Exception as e:
logger.error(
f"Failed to emit batch state change notification: {e}",
extra={"tenant_id": tenant_id, "batch_id": batch_id},
exc_info=True,
)
async def emit_batch_completed_notification(
self,
db: Session,
tenant_id: str,
batch_id: str,
product_sku: str,
product_name: str,
quantity_produced: float,
unit: str,
production_duration_minutes: Optional[int] = None,
quality_score: Optional[float] = None,
) -> None:
"""
Emit notification when a production batch is completed.
Args:
db: Database session
tenant_id: Tenant ID
batch_id: Production batch ID
product_sku: Product SKU
product_name: Product name
quantity_produced: Quantity produced
unit: Unit of measurement
production_duration_minutes: Total production time (optional)
quality_score: Quality score (0-100, optional)
"""
try:
message = f"Produced {quantity_produced} {unit} of {product_name}"
if production_duration_minutes:
message += f" in {production_duration_minutes} minutes"
if quality_score:
message += f" (Quality: {quality_score:.1f}%)"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.PRODUCTION,
event_type="batch_completed",
title=f"Batch Completed: {product_name}",
message=message,
service="production",
event_metadata={
"batch_id": batch_id,
"product_sku": product_sku,
"product_name": product_name,
"quantity_produced": quantity_produced,
"unit": unit,
"production_duration_minutes": production_duration_minutes,
"quality_score": quality_score,
"completed_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Batch completed notification emitted: {batch_id} ({quantity_produced} {unit})",
extra={"tenant_id": tenant_id, "batch_id": batch_id}
)
except Exception as e:
logger.error(
f"Failed to emit batch completed notification: {e}",
extra={"tenant_id": tenant_id, "batch_id": batch_id},
exc_info=True,
)
async def emit_batch_started_notification(
self,
db: Session,
tenant_id: str,
batch_id: str,
product_sku: str,
product_name: str,
quantity_planned: float,
unit: str,
estimated_duration_minutes: Optional[int] = None,
assigned_to: Optional[str] = None,
) -> None:
"""
Emit notification when a production batch is started.
Args:
db: Database session
tenant_id: Tenant ID
batch_id: Production batch ID
product_sku: Product SKU
product_name: Product name
quantity_planned: Planned quantity
unit: Unit of measurement
estimated_duration_minutes: Estimated duration (optional)
assigned_to: Assigned worker/station (optional)
"""
try:
message = f"Started production of {quantity_planned} {unit} of {product_name}"
if estimated_duration_minutes:
message += f" (Est. {estimated_duration_minutes} min)"
if assigned_to:
message += f" - Assigned to {assigned_to}"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.PRODUCTION,
event_type="batch_started",
title=f"Batch Started: {product_name}",
message=message,
service="production",
event_metadata={
"batch_id": batch_id,
"product_sku": product_sku,
"product_name": product_name,
"quantity_planned": quantity_planned,
"unit": unit,
"estimated_duration_minutes": estimated_duration_minutes,
"assigned_to": assigned_to,
"started_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Batch started notification emitted: {batch_id}",
extra={"tenant_id": tenant_id, "batch_id": batch_id}
)
except Exception as e:
logger.error(
f"Failed to emit batch started notification: {e}",
extra={"tenant_id": tenant_id, "batch_id": batch_id},
exc_info=True,
)
async def emit_equipment_status_notification(
self,
db: Session,
tenant_id: str,
equipment_id: str,
equipment_name: str,
old_status: str,
new_status: str,
reason: Optional[str] = None,
) -> None:
"""
Emit notification when equipment status changes.
Args:
db: Database session
tenant_id: Tenant ID
equipment_id: Equipment ID
equipment_name: Equipment name
old_status: Previous status
new_status: New status
reason: Reason for status change (optional)
"""
try:
message = f"{equipment_name} status: {old_status}{new_status}"
if reason:
message += f" - {reason}"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.PRODUCTION,
event_type="equipment_status_changed",
title=f"Equipment Status: {equipment_name}",
message=message,
service="production",
event_metadata={
"equipment_id": equipment_id,
"equipment_name": equipment_name,
"old_status": old_status,
"new_status": new_status,
"reason": reason,
"status_changed_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Equipment status notification emitted: {equipment_name}",
extra={"tenant_id": tenant_id, "equipment_id": equipment_id}
)
except Exception as e:
logger.error(
f"Failed to emit equipment status notification: {e}",
extra={"tenant_id": tenant_id, "equipment_id": equipment_id},
exc_info=True,
)