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

@@ -0,0 +1,395 @@
"""
Procurement Event Service
Emits both ALERTS and NOTIFICATIONS for procurement/supply chain events:
ALERTS (actionable):
- po_approval_needed: Purchase order requires approval
- po_approval_escalation: PO pending approval too long
- delivery_overdue: Delivery past expected date
NOTIFICATIONS (informational):
- po_approved: Purchase order approved
- po_rejected: Purchase order rejected
- po_sent_to_supplier: PO sent to supplier
- delivery_scheduled: Delivery confirmed
- delivery_arriving_soon: Delivery arriving within hours
- delivery_received: Delivery arrived
This service demonstrates the mixed event model where a single domain
emits both actionable alerts and informational notifications.
"""
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any, List
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 ProcurementEventService(BaseAlertService):
"""
Service for emitting procurement/supply chain events (both alerts and notifications).
"""
def __init__(self, rabbitmq_url: str = None):
super().__init__(service_name="procurement", rabbitmq_url=rabbitmq_url)
# ============================================================
# ALERTS (Actionable)
# ============================================================
async def emit_po_approval_needed_alert(
self,
db: Session,
tenant_id: str,
po_id: str,
supplier_name: str,
total_amount_eur: float,
items_count: int,
urgency_reason: str,
delivery_needed_by: Optional[datetime] = None,
) -> None:
"""
Emit ALERT when purchase order requires approval.
This is an ALERT (not notification) because it requires user action.
"""
try:
message = f"Purchase order from {supplier_name} needs approval (€{total_amount_eur:.2f}, {items_count} items)"
if delivery_needed_by:
days_until_needed = (delivery_needed_by - datetime.now(timezone.utc)).days
message += f" - Needed in {days_until_needed} days"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.ALERT,
event_domain=EventDomain.SUPPLY_CHAIN,
event_type="po_approval_needed",
title=f"Approval Required: PO from {supplier_name}",
message=message,
service="procurement",
actions=["approve_po", "reject_po", "view_po_details"],
event_metadata={
"po_id": po_id,
"supplier_name": supplier_name,
"total_amount_eur": total_amount_eur,
"items_count": items_count,
"urgency_reason": urgency_reason,
"delivery_needed_by": delivery_needed_by.isoformat() if delivery_needed_by else None,
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="alert")
logger.info(
f"PO approval alert emitted: {po_id} (€{total_amount_eur})",
extra={"tenant_id": tenant_id, "po_id": po_id}
)
except Exception as e:
logger.error(
f"Failed to emit PO approval alert: {e}",
extra={"tenant_id": tenant_id, "po_id": po_id},
exc_info=True,
)
async def emit_delivery_overdue_alert(
self,
db: Session,
tenant_id: str,
delivery_id: str,
po_id: str,
supplier_name: str,
expected_date: datetime,
days_overdue: int,
items_affected: List[Dict[str, Any]],
) -> None:
"""
Emit ALERT when delivery is overdue.
This is an ALERT because it may require contacting supplier or adjusting plans.
"""
try:
message = f"Delivery from {supplier_name} is {days_overdue} days overdue (expected {expected_date.strftime('%Y-%m-%d')})"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.ALERT,
event_domain=EventDomain.SUPPLY_CHAIN,
event_type="delivery_overdue",
title=f"Delivery Overdue: {supplier_name}",
message=message,
service="procurement",
actions=["call_supplier", "adjust_production", "find_alternative"],
event_metadata={
"delivery_id": delivery_id,
"po_id": po_id,
"supplier_name": supplier_name,
"expected_date": expected_date.isoformat(),
"days_overdue": days_overdue,
"items_affected": items_affected,
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="alert")
logger.info(
f"Delivery overdue alert emitted: {delivery_id} ({days_overdue} days)",
extra={"tenant_id": tenant_id, "delivery_id": delivery_id}
)
except Exception as e:
logger.error(
f"Failed to emit delivery overdue alert: {e}",
extra={"tenant_id": tenant_id, "delivery_id": delivery_id},
exc_info=True,
)
# ============================================================
# NOTIFICATIONS (Informational)
# ============================================================
async def emit_po_approved_notification(
self,
db: Session,
tenant_id: str,
po_id: str,
supplier_name: str,
total_amount_eur: float,
approved_by: str,
expected_delivery_date: Optional[datetime] = None,
) -> None:
"""
Emit NOTIFICATION when purchase order is approved.
This is a NOTIFICATION (not alert) - informational only, no action needed.
"""
try:
message = f"Purchase order to {supplier_name} approved by {approved_by} (€{total_amount_eur:.2f})"
if expected_delivery_date:
message += f" - Expected delivery: {expected_delivery_date.strftime('%Y-%m-%d')}"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.SUPPLY_CHAIN,
event_type="po_approved",
title=f"PO Approved: {supplier_name}",
message=message,
service="procurement",
event_metadata={
"po_id": po_id,
"supplier_name": supplier_name,
"total_amount_eur": total_amount_eur,
"approved_by": approved_by,
"expected_delivery_date": expected_delivery_date.isoformat() if expected_delivery_date else None,
"approved_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"PO approved notification emitted: {po_id}",
extra={"tenant_id": tenant_id, "po_id": po_id}
)
except Exception as e:
logger.error(
f"Failed to emit PO approved notification: {e}",
extra={"tenant_id": tenant_id, "po_id": po_id},
exc_info=True,
)
async def emit_po_sent_to_supplier_notification(
self,
db: Session,
tenant_id: str,
po_id: str,
supplier_name: str,
supplier_email: str,
) -> None:
"""
Emit NOTIFICATION when PO is sent to supplier.
"""
try:
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.SUPPLY_CHAIN,
event_type="po_sent_to_supplier",
title=f"PO Sent: {supplier_name}",
message=f"Purchase order sent to {supplier_name} ({supplier_email})",
service="procurement",
event_metadata={
"po_id": po_id,
"supplier_name": supplier_name,
"supplier_email": supplier_email,
"sent_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"PO sent notification emitted: {po_id}",
extra={"tenant_id": tenant_id, "po_id": po_id}
)
except Exception as e:
logger.error(
f"Failed to emit PO sent notification: {e}",
extra={"tenant_id": tenant_id, "po_id": po_id},
exc_info=True,
)
async def emit_delivery_scheduled_notification(
self,
db: Session,
tenant_id: str,
delivery_id: str,
po_id: str,
supplier_name: str,
expected_delivery_date: datetime,
tracking_number: Optional[str] = None,
) -> None:
"""
Emit NOTIFICATION when delivery is scheduled/confirmed.
"""
try:
message = f"Delivery from {supplier_name} scheduled for {expected_delivery_date.strftime('%Y-%m-%d %H:%M')}"
if tracking_number:
message += f" (Tracking: {tracking_number})"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.SUPPLY_CHAIN,
event_type="delivery_scheduled",
title=f"Delivery Scheduled: {supplier_name}",
message=message,
service="procurement",
event_metadata={
"delivery_id": delivery_id,
"po_id": po_id,
"supplier_name": supplier_name,
"expected_delivery_date": expected_delivery_date.isoformat(),
"tracking_number": tracking_number,
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Delivery scheduled notification emitted: {delivery_id}",
extra={"tenant_id": tenant_id, "delivery_id": delivery_id}
)
except Exception as e:
logger.error(
f"Failed to emit delivery scheduled notification: {e}",
extra={"tenant_id": tenant_id, "delivery_id": delivery_id},
exc_info=True,
)
async def emit_delivery_arriving_soon_notification(
self,
db: Session,
tenant_id: str,
delivery_id: str,
supplier_name: str,
expected_arrival_time: datetime,
hours_until_arrival: int,
) -> None:
"""
Emit NOTIFICATION when delivery is arriving soon (within hours).
"""
try:
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.SUPPLY_CHAIN,
event_type="delivery_arriving_soon",
title=f"Delivery Arriving Soon: {supplier_name}",
message=f"Delivery from {supplier_name} arriving in {hours_until_arrival} hours",
service="procurement",
event_metadata={
"delivery_id": delivery_id,
"supplier_name": supplier_name,
"expected_arrival_time": expected_arrival_time.isoformat(),
"hours_until_arrival": hours_until_arrival,
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Delivery arriving soon notification emitted: {delivery_id}",
extra={"tenant_id": tenant_id, "delivery_id": delivery_id}
)
except Exception as e:
logger.error(
f"Failed to emit delivery arriving soon notification: {e}",
extra={"tenant_id": tenant_id, "delivery_id": delivery_id},
exc_info=True,
)
async def emit_delivery_received_notification(
self,
db: Session,
tenant_id: str,
delivery_id: str,
po_id: str,
supplier_name: str,
items_received: int,
received_by: str,
) -> None:
"""
Emit NOTIFICATION when delivery is received.
"""
try:
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.SUPPLY_CHAIN,
event_type="delivery_received",
title=f"Delivery Received: {supplier_name}",
message=f"Received {items_received} items from {supplier_name} - Checked by {received_by}",
service="procurement",
event_metadata={
"delivery_id": delivery_id,
"po_id": po_id,
"supplier_name": supplier_name,
"items_received": items_received,
"received_by": received_by,
"received_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"Delivery received notification emitted: {delivery_id}",
extra={"tenant_id": tenant_id, "delivery_id": delivery_id}
)
except Exception as e:
logger.error(
f"Failed to emit delivery received notification: {e}",
extra={"tenant_id": tenant_id, "delivery_id": delivery_id},
exc_info=True,
)

View File

@@ -165,6 +165,22 @@ class PurchaseOrderService:
po_number=po_number,
total_amount=float(total_amount))
# Emit alert if PO requires approval
if initial_status == 'pending_approval':
try:
await self._emit_po_approval_alert(
tenant_id=tenant_id,
purchase_order=purchase_order,
supplier=supplier
)
except Exception as alert_error:
# Log but don't fail PO creation if alert emission fails
logger.warning(
"Failed to emit PO approval alert",
po_id=str(purchase_order.id),
error=str(alert_error)
)
return purchase_order
except Exception as e:
@@ -704,6 +720,78 @@ class PurchaseOrderService:
# PRIVATE HELPER METHODS
# ================================================================
async def _emit_po_approval_alert(
self,
tenant_id: uuid.UUID,
purchase_order: PurchaseOrder,
supplier: Dict[str, Any]
) -> None:
"""Emit raw alert for PO approval needed with structured parameters"""
try:
# Prepare alert payload matching RawAlert schema
alert_data = {
'id': str(uuid.uuid4()), # Generate unique alert ID
'tenant_id': str(tenant_id),
'service': 'procurement',
'type': 'po_approval_needed',
'alert_type': 'po_approval_needed', # Added for dashboard filtering
'type_class': 'action_needed', # Critical for dashboard action queue
'severity': 'high' if purchase_order.priority == 'critical' else 'medium',
'title': '', # Empty - will be generated by frontend with i18n
'message': '', # Empty - will be generated by frontend with i18n
'timestamp': datetime.utcnow().isoformat(),
'metadata': {
'po_id': str(purchase_order.id),
'po_number': purchase_order.po_number,
'supplier_id': str(purchase_order.supplier_id),
'supplier_name': supplier.get('name', ''),
'total_amount': float(purchase_order.total_amount),
'currency': purchase_order.currency,
'priority': purchase_order.priority,
'required_delivery_date': purchase_order.required_delivery_date.isoformat() if purchase_order.required_delivery_date else None,
'created_at': purchase_order.created_at.isoformat(),
# Add urgency context for dashboard prioritization
'financial_impact': float(purchase_order.total_amount),
'urgency_score': 85, # Default high urgency for pending approvals
# Include reasoning data from orchestrator (if available)
'reasoning_data': purchase_order.reasoning_data if purchase_order.reasoning_data else None
},
'message_params': {
'po_number': purchase_order.po_number,
'supplier_name': supplier.get('name', ''),
'total_amount': float(purchase_order.total_amount),
'currency': purchase_order.currency,
'priority': purchase_order.priority,
'required_delivery_date': purchase_order.required_delivery_date.isoformat() if purchase_order.required_delivery_date else None,
'items_count': len(purchase_order.items) if hasattr(purchase_order, 'items') else 0,
'created_at': purchase_order.created_at.isoformat()
},
'actions': ['approve_po', 'reject_po', 'modify_po'],
'item_type': 'alert'
}
# Publish to RabbitMQ
await self.rabbitmq_client.publish_event(
exchange_name='alerts.exchange',
routing_key=f'alert.{alert_data["severity"]}.procurement',
event_data=alert_data
)
logger.info(
"PO approval alert emitted",
po_id=str(purchase_order.id),
po_number=purchase_order.po_number,
tenant_id=str(tenant_id)
)
except Exception as e:
logger.error(
"Failed to emit PO approval alert",
po_id=str(purchase_order.id),
error=str(e)
)
raise
async def _get_and_validate_supplier(self, tenant_id: uuid.UUID, supplier_id: uuid.UUID) -> Dict[str, Any]:
"""Get and validate supplier from Suppliers Service"""
try: