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

@@ -16,7 +16,6 @@ from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import text
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
from shared.alerts.templates import format_item_message
from app.repositories.stock_repository import StockRepository
from app.repositories.stock_movement_repository import StockMovementRepository
from app.repositories.inventory_alert_repository import InventoryAlertRepository
@@ -122,78 +121,73 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
self._errors_count += 1
async def _process_stock_issue(self, tenant_id: UUID, issue: Dict[str, Any]):
"""Process individual stock issue"""
"""Process individual stock issue - sends raw data, enrichment generates contextual message"""
try:
if issue['status'] == 'critical':
# Critical stock shortage - immediate alert
template_data = self.format_spanish_message(
'critical_stock_shortage',
ingredient_name=issue["name"],
current_stock=issue["current_stock"],
required_stock=issue["tomorrow_needed"] or issue["minimum_stock"],
shortage_amount=issue["shortage_amount"]
)
# Critical stock shortage - send raw data for enrichment
await self.publish_item(tenant_id, {
'type': 'critical_stock_shortage',
'severity': 'urgent',
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched', # Placeholder, will be replaced
'message': 'Raw Alert - Will be enriched', # Placeholder, will be replaced
'actions': [], # Will be generated during enrichment
'metadata': {
'ingredient_id': str(issue['id']),
'ingredient_name': issue["name"],
'current_stock': float(issue['current_stock']),
'minimum_stock': float(issue['minimum_stock']),
'required_stock': float(issue["tomorrow_needed"] or issue["minimum_stock"]),
'shortage_amount': float(issue['shortage_amount']),
'tomorrow_needed': float(issue['tomorrow_needed'] or 0),
'lead_time_days': issue['lead_time_days']
'lead_time_days': issue.get('lead_time_days'),
'supplier_name': issue.get('supplier_name'),
'supplier_phone': issue.get('supplier_phone'),
'hours_until_stockout': issue.get('hours_until_stockout')
}
}, item_type='alert')
elif issue['status'] == 'low':
# Low stock - high priority alert
template_data = self.format_spanish_message(
'critical_stock_shortage',
ingredient_name=issue["name"],
current_stock=issue["current_stock"],
required_stock=issue["minimum_stock"]
)
# Low stock - send raw data for enrichment
severity = self.get_business_hours_severity('high')
await self.publish_item(tenant_id, {
'type': 'low_stock_warning',
'severity': severity,
'title': f'⚠️ Stock Bajo: {issue["name"]}',
'message': f'Stock actual {issue["current_stock"]}kg, mínimo {issue["minimum_stock"]}kg. Considerar pedido pronto.',
'actions': ['Revisar consumo', 'Programar pedido', 'Contactar proveedor'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'ingredient_id': str(issue['id']),
'ingredient_name': issue["name"],
'current_stock': float(issue['current_stock']),
'minimum_stock': float(issue['minimum_stock'])
'minimum_stock': float(issue['minimum_stock']),
'supplier_name': issue.get('supplier_name'),
'supplier_phone': issue.get('supplier_phone')
}
}, item_type='alert')
elif issue['status'] == 'overstock':
# Overstock - medium priority alert
# Overstock - send raw data for enrichment
severity = self.get_business_hours_severity('medium')
await self.publish_item(tenant_id, {
'type': 'overstock_warning',
'severity': severity,
'title': f'📦 Exceso de Stock: {issue["name"]}',
'message': f'Stock actual {issue["current_stock"]}kg excede máximo {issue["maximum_stock"]}kg. Revisar para evitar caducidad.',
'actions': ['Revisar caducidades', 'Aumentar producción', 'Ofertas especiales', 'Ajustar pedidos'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'ingredient_id': str(issue['id']),
'ingredient_name': issue["name"],
'current_stock': float(issue['current_stock']),
'maximum_stock': float(issue['maximum_stock'])
'maximum_stock': float(issue.get('maximum_stock', 0)),
'waste_risk_kg': float(issue.get('waste_risk_kg', 0))
}
}, item_type='alert')
except Exception as e:
logger.error("Error processing stock issue",
ingredient_id=str(issue.get('id')),
logger.error("Error processing stock issue",
ingredient_id=str(issue.get('id')),
error=str(e))
async def check_expiring_products(self):

View File

@@ -0,0 +1,246 @@
"""
Inventory Notification Service
Emits informational notifications for inventory state changes:
- stock_received: When deliveries arrive
- stock_movement: Transfers, adjustments
- stock_updated: General stock updates
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 InventoryNotificationService(BaseAlertService):
"""
Service for emitting inventory notifications (informational state changes).
"""
def __init__(self, rabbitmq_url: str = None):
super().__init__(service_name="inventory", rabbitmq_url=rabbitmq_url)
async def emit_stock_received_notification(
self,
db: Session,
tenant_id: str,
stock_receipt_id: str,
ingredient_id: str,
ingredient_name: str,
quantity_received: float,
unit: str,
supplier_name: Optional[str] = None,
delivery_id: Optional[str] = None,
) -> None:
"""
Emit notification when stock is received.
Args:
db: Database session
tenant_id: Tenant ID
stock_receipt_id: Stock receipt ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
quantity_received: Quantity received
unit: Unit of measurement
supplier_name: Supplier name (optional)
delivery_id: Delivery ID (optional)
"""
try:
# Create notification event
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_received",
title=f"Stock Received: {ingredient_name}",
message=f"Received {quantity_received} {unit} of {ingredient_name}"
+ (f" from {supplier_name}" if supplier_name else ""),
service="inventory",
event_metadata={
"stock_receipt_id": stock_receipt_id,
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"quantity_received": quantity_received,
"unit": unit,
"supplier_name": supplier_name,
"delivery_id": delivery_id,
"received_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"Stock received notification emitted: {ingredient_name} ({quantity_received} {unit})",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
)
except Exception as e:
logger.error(
f"Failed to emit stock received notification: {e}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
exc_info=True,
)
async def emit_stock_movement_notification(
self,
db: Session,
tenant_id: str,
movement_id: str,
ingredient_id: str,
ingredient_name: str,
quantity: float,
unit: str,
movement_type: str, # 'transfer', 'adjustment', 'waste', 'return'
from_location: Optional[str] = None,
to_location: Optional[str] = None,
reason: Optional[str] = None,
) -> None:
"""
Emit notification for stock movements (transfers, adjustments, waste).
Args:
db: Database session
tenant_id: Tenant ID
movement_id: Movement ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
quantity: Quantity moved
unit: Unit of measurement
movement_type: Type of movement
from_location: Source location (optional)
to_location: Destination location (optional)
reason: Reason for movement (optional)
"""
try:
# Build message based on movement type
if movement_type == "transfer":
message = f"Transferred {quantity} {unit} of {ingredient_name}"
if from_location and to_location:
message += f" from {from_location} to {to_location}"
elif movement_type == "adjustment":
message = f"Adjusted {ingredient_name} by {quantity} {unit}"
if reason:
message += f" - {reason}"
elif movement_type == "waste":
message = f"Waste recorded: {quantity} {unit} of {ingredient_name}"
if reason:
message += f" - {reason}"
elif movement_type == "return":
message = f"Returned {quantity} {unit} of {ingredient_name}"
else:
message = f"Stock movement: {quantity} {unit} of {ingredient_name}"
# Create notification event
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_movement",
title=f"Stock {movement_type.title()}: {ingredient_name}",
message=message,
service="inventory",
event_metadata={
"movement_id": movement_id,
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"quantity": quantity,
"unit": unit,
"movement_type": movement_type,
"from_location": from_location,
"to_location": to_location,
"reason": reason,
"moved_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
# Publish to RabbitMQ
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Stock movement notification emitted: {movement_type} - {ingredient_name}",
extra={"tenant_id": tenant_id, "movement_id": movement_id}
)
except Exception as e:
logger.error(
f"Failed to emit stock movement notification: {e}",
extra={"tenant_id": tenant_id, "movement_id": movement_id},
exc_info=True,
)
async def emit_stock_updated_notification(
self,
db: Session,
tenant_id: str,
ingredient_id: str,
ingredient_name: str,
old_quantity: float,
new_quantity: float,
unit: str,
update_reason: str,
) -> None:
"""
Emit notification when stock is updated.
Args:
db: Database session
tenant_id: Tenant ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
old_quantity: Previous quantity
new_quantity: New quantity
unit: Unit of measurement
update_reason: Reason for update
"""
try:
quantity_change = new_quantity - old_quantity
change_direction = "increased" if quantity_change > 0 else "decreased"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_updated",
title=f"Stock Updated: {ingredient_name}",
message=f"Stock {change_direction} by {abs(quantity_change)} {unit} - {update_reason}",
service="inventory",
event_metadata={
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"old_quantity": old_quantity,
"new_quantity": new_quantity,
"quantity_change": quantity_change,
"unit": unit,
"update_reason": update_reason,
"updated_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"Stock updated notification emitted: {ingredient_name}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
)
except Exception as e:
logger.error(
f"Failed to emit stock updated notification: {e}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
exc_info=True,
)