New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,33 @@
"""
Production Notification Service
Production Notification Service - Simplified
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
Emits minimal events using EventPublisher.
All enrichment handled by alert_processor.
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 uuid import UUID
import structlog
from shared.schemas.event_classification import RawEvent, EventClass, EventDomain
from shared.alerts.base_service import BaseAlertService
from shared.messaging import UnifiedEventPublisher
logger = structlog.get_logger()
logger = logging.getLogger(__name__)
class ProductionNotificationService(BaseAlertService):
class ProductionNotificationService:
"""
Service for emitting production notifications (informational state changes).
Service for emitting production notifications using EventPublisher.
"""
def __init__(self, rabbitmq_url: str = None):
super().__init__(service_name="production", rabbitmq_url=rabbitmq_url)
def __init__(self, event_publisher: UnifiedEventPublisher):
self.publisher = event_publisher
async def emit_batch_state_changed_notification(
self,
db: Session,
tenant_id: str,
tenant_id: UUID,
batch_id: str,
product_sku: str,
product_name: str,
@@ -44,76 +39,50 @@ class ProductionNotificationService(BaseAlertService):
) -> 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}",
}
# 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}"
)
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),
)
metadata = {
"batch_id": batch_id,
"product_sku": product_sku,
"product_name": product_name,
"old_status": old_status,
"new_status": new_status,
"quantity": float(quantity),
"unit": unit,
"assigned_to": assigned_to,
"state_changed_at": datetime.now(timezone.utc).isoformat(),
}
# Publish to RabbitMQ for processing
await self.publish_item(tenant_id, event.dict(), item_type="notification")
await self.publisher.publish_notification(
event_type="production.batch_state_changed",
tenant_id=tenant_id,
data=metadata
)
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,
)
logger.info(
"batch_state_changed_notification_emitted",
tenant_id=str(tenant_id),
batch_id=batch_id,
old_status=old_status,
new_status=new_status
)
async def emit_batch_completed_notification(
self,
db: Session,
tenant_id: str,
tenant_id: UUID,
batch_id: str,
product_sku: str,
product_name: str,
@@ -124,64 +93,42 @@ class ProductionNotificationService(BaseAlertService):
) -> 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}%)"
message_parts = [f"Produced {quantity_produced} {unit} of {product_name}"]
if production_duration_minutes:
message_parts.append(f"in {production_duration_minutes} minutes")
if quality_score:
message_parts.append(f"(Quality: {quality_score:.1f}%)")
message = " ".join(message_parts)
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),
)
metadata = {
"batch_id": batch_id,
"product_sku": product_sku,
"product_name": product_name,
"quantity_produced": float(quantity_produced),
"unit": unit,
"production_duration_minutes": production_duration_minutes,
"quality_score": quality_score,
"completed_at": datetime.now(timezone.utc).isoformat(),
}
await self.publish_item(tenant_id, event.dict(), item_type="notification")
await self.publisher.publish_notification(
event_type="production.batch_completed",
tenant_id=tenant_id,
data=metadata
)
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,
)
logger.info(
"batch_completed_notification_emitted",
tenant_id=str(tenant_id),
batch_id=batch_id,
quantity_produced=quantity_produced
)
async def emit_batch_started_notification(
self,
db: Session,
tenant_id: str,
tenant_id: UUID,
batch_id: str,
product_sku: str,
product_name: str,
@@ -192,64 +139,41 @@ class ProductionNotificationService(BaseAlertService):
) -> 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}"
message_parts = [f"Started production of {quantity_planned} {unit} of {product_name}"]
if estimated_duration_minutes:
message_parts.append(f"(Est. {estimated_duration_minutes} min)")
if assigned_to:
message_parts.append(f"- Assigned to {assigned_to}")
message = " ".join(message_parts)
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),
)
metadata = {
"batch_id": batch_id,
"product_sku": product_sku,
"product_name": product_name,
"quantity_planned": float(quantity_planned),
"unit": unit,
"estimated_duration_minutes": estimated_duration_minutes,
"assigned_to": assigned_to,
"started_at": datetime.now(timezone.utc).isoformat(),
}
await self.publish_item(tenant_id, event.dict(), item_type="notification")
await self.publisher.publish_notification(
event_type="production.batch_started",
tenant_id=tenant_id,
data=metadata
)
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,
)
logger.info(
"batch_started_notification_emitted",
tenant_id=str(tenant_id),
batch_id=batch_id
)
async def emit_equipment_status_notification(
self,
db: Session,
tenant_id: str,
tenant_id: UUID,
equipment_id: str,
equipment_name: str,
old_status: str,
@@ -258,50 +182,29 @@ class ProductionNotificationService(BaseAlertService):
) -> 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}"
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),
)
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(),
}
await self.publish_item(tenant_id, event.dict(), item_type="notification")
await self.publisher.publish_notification(
event_type="production.equipment_status_changed",
tenant_id=tenant_id,
data=metadata
)
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,
)
logger.info(
"equipment_status_notification_emitted",
tenant_id=str(tenant_id),
equipment_id=equipment_id,
new_status=new_status
)

View File

@@ -24,6 +24,7 @@ from app.schemas.production import (
ProductionScheduleCreate, ProductionScheduleUpdate, ProductionScheduleResponse,
DailyProductionRequirements, ProductionDashboardSummary, ProductionMetrics
)
from app.utils.cache import delete_cached, make_cache_key
logger = structlog.get_logger()
@@ -324,12 +325,17 @@ class ProductionService:
await self._update_inventory_on_completion(
tenant_id, batch, status_update.actual_quantity
)
logger.info("Updated batch status",
batch_id=str(batch_id),
# PHASE 2: Invalidate production dashboard cache
cache_key = make_cache_key("production_dashboard", str(tenant_id))
await delete_cached(cache_key)
logger.debug("Invalidated production dashboard cache", cache_key=cache_key, tenant_id=str(tenant_id))
logger.info("Updated batch status",
batch_id=str(batch_id),
new_status=status_update.status.value,
tenant_id=str(tenant_id))
return batch
except Exception as e:
@@ -658,7 +664,26 @@ class ProductionService:
logger.info("Started production batch",
batch_id=str(batch_id), tenant_id=str(tenant_id))
return batch
# Acknowledge production delay alerts (non-blocking)
try:
from shared.clients.alert_processor_client import get_alert_processor_client
alert_client = get_alert_processor_client(self.config, "production")
await alert_client.acknowledge_alerts_by_metadata(
tenant_id=tenant_id,
alert_type="production_delay",
metadata_filter={"batch_id": str(batch_id)}
)
await alert_client.acknowledge_alerts_by_metadata(
tenant_id=tenant_id,
alert_type="batch_at_risk",
metadata_filter={"batch_id": str(batch_id)}
)
logger.debug("Acknowledged production delay alerts", batch_id=str(batch_id))
except Exception as e:
# Log but don't fail the batch start
logger.warning("Failed to acknowledge production alerts", batch_id=str(batch_id), error=str(e))
return batch
except Exception as e:
logger.error("Error starting production batch",