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

@@ -17,6 +17,8 @@ from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem
from app.models.replenishment import ReplenishmentPlan, ReplenishmentPlanItem
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
from shared.messaging.rabbitmq import RabbitMQClient
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
@@ -267,6 +269,20 @@ async def clone_demo_data(
# Generate a system user UUID for audit fields (demo purposes)
system_user_id = uuid.uuid4()
# For demo sessions: 30-40% of POs should have delivery scheduled for TODAY
# This ensures the ExecutionProgressTracker shows realistic delivery data
import random
expected_delivery = None
if order.status in ['approved', 'sent_to_supplier'] and random.random() < 0.35:
# Set delivery for today at various times (8am-6pm)
hours_offset = random.randint(8, 18)
minutes_offset = random.choice([0, 15, 30, 45])
expected_delivery = session_time.replace(hour=hours_offset, minute=minutes_offset, second=0, microsecond=0)
else:
# Use the adjusted estimated delivery date
expected_delivery = adjusted_estimated_delivery
# Create new PurchaseOrder - add expected_delivery_date only if column exists (after migration)
new_order = PurchaseOrder(
id=new_order_id,
tenant_id=virtual_uuid,
@@ -307,6 +323,11 @@ async def clone_demo_data(
created_by=system_user_id,
updated_by=system_user_id
)
# Add expected_delivery_date if the model supports it (after migration)
if hasattr(PurchaseOrder, 'expected_delivery_date'):
new_order.expected_delivery_date = expected_delivery
db.add(new_order)
stats["purchase_orders"] += 1
@@ -415,12 +436,116 @@ async def clone_demo_data(
await db.commit()
total_records = sum(stats.values())
# EMIT ALERTS FOR PENDING APPROVAL POs
# After cloning, emit PO approval alerts for any pending_approval POs
# This ensures the action queue is populated when the demo session starts
pending_pos_for_alerts = []
for order_id in order_id_map.values():
result = await db.execute(
select(PurchaseOrder).where(
PurchaseOrder.id == order_id,
PurchaseOrder.status == 'pending_approval'
)
)
po = result.scalar_one_or_none()
if po:
pending_pos_for_alerts.append(po)
logger.info(
"Emitting PO approval alerts for cloned pending POs",
pending_po_count=len(pending_pos_for_alerts),
virtual_tenant_id=virtual_tenant_id
)
# Initialize RabbitMQ client for alert emission
alerts_emitted = 0
if pending_pos_for_alerts:
rabbitmq_client = RabbitMQClient(settings.RABBITMQ_URL, "procurement")
try:
await rabbitmq_client.connect()
for po in pending_pos_for_alerts:
try:
# Get deadline for urgency calculation
now_utc = datetime.now(timezone.utc)
if po.required_delivery_date:
deadline = po.required_delivery_date
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
else:
days_until = 3 if po.priority == 'critical' else 7
deadline = now_utc + timedelta(days=days_until)
hours_until = (deadline - now_utc).total_seconds() / 3600
# Prepare alert payload
alert_data = {
'id': str(uuid.uuid4()),
'tenant_id': str(virtual_uuid),
'service': 'procurement',
'type': 'po_approval_needed',
'alert_type': 'po_approval_needed',
'type_class': 'action_needed',
'severity': 'high' if po.priority == 'critical' else 'medium',
'title': f'Purchase Order #{po.po_number} requires approval',
'message': f'Purchase order totaling {po.currency} {po.total_amount:.2f} is pending approval.',
'timestamp': now_utc.isoformat(),
'metadata': {
'po_id': str(po.id),
'po_number': po.po_number,
'supplier_id': str(po.supplier_id),
'supplier_name': f'Supplier-{po.supplier_id}', # Simplified for demo
'total_amount': float(po.total_amount),
'currency': po.currency,
'priority': po.priority,
'required_delivery_date': po.required_delivery_date.isoformat() if po.required_delivery_date else None,
'created_at': po.created_at.isoformat(),
'financial_impact': float(po.total_amount),
'deadline': deadline.isoformat(),
'hours_until_consequence': int(hours_until),
'reasoning_data': po.reasoning_data if po.reasoning_data else None, # Include orchestrator reasoning
},
'actions': ['approve_po', 'reject_po', 'modify_po'],
'item_type': 'alert'
}
# Publish to RabbitMQ
success = await rabbitmq_client.publish_event(
exchange_name='alerts.exchange',
routing_key=f'alert.{alert_data["severity"]}.procurement',
event_data=alert_data
)
if success:
alerts_emitted += 1
logger.info(
"PO approval alert emitted during cloning",
po_id=str(po.id),
po_number=po.po_number,
tenant_id=str(virtual_uuid)
)
except Exception as e:
logger.error(
"Failed to emit PO approval alert during cloning",
po_id=str(po.id),
error=str(e)
)
# Continue with other POs
continue
finally:
await rabbitmq_client.disconnect()
stats["alerts_emitted"] = alerts_emitted
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Procurement data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
alerts_emitted=alerts_emitted,
stats=stats,
duration_ms=duration_ms
)

View File

@@ -84,6 +84,7 @@ class PurchaseOrder(Base):
order_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
required_delivery_date = Column(DateTime(timezone=True), nullable=True) # Stored as DateTime for consistency
estimated_delivery_date = Column(DateTime(timezone=True), nullable=True)
expected_delivery_date = Column(DateTime(timezone=True), nullable=True) # When delivery is actually expected (used for dashboard tracking)
# Financial information
subtotal = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))

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: