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

@@ -38,6 +38,10 @@ import structlog
from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
# Add shared path for demo utilities
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from shared.utils.demo_dates import BASE_REFERENCE_DATE
# Configure logging
structlog.configure(
processors=[
@@ -53,9 +57,6 @@ logger = structlog.get_logger()
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
# Hardcoded SKU to Ingredient ID mapping (no database lookups needed!)
INGREDIENT_ID_MAP = {
"HAR-T55-001": "10000000-0000-0000-0000-000000000001",

View File

@@ -579,16 +579,74 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
po9.notes = "DISPUTED: Quality issue reported - batch rejected, requesting replacement or refund"
pos_created.append(po9)
# ============================================================================
# DASHBOARD SHOWCASE SCENARIOS - These create specific alert conditions
# ============================================================================
# 10. PO APPROVAL ESCALATION - Pending for 72+ hours (URGENT dashboard alert)
po10 = await create_purchase_order(
db, tenant_id, supplier_medium_trust,
PurchaseOrderStatus.pending_approval,
Decimal("450.00"),
created_offset_days=-3, # Created 3 days (72 hours) ago
priority="high",
items_data=[
{"name": "Levadura Seca", "quantity": 50, "unit_price": 6.90, "uom": "kg"},
{"name": "Sal Fina", "quantity": 30, "unit_price": 0.85, "uom": "kg"}
]
)
po10.notes = "⚠️ ESCALATED: Pending approval for 72+ hours - Production batch depends on tomorrow morning delivery"
pos_created.append(po10)
# 11. DELIVERY OVERDUE - Expected delivery is 4 hours late (URGENT dashboard alert)
delivery_overdue_time = datetime.now(timezone.utc) - timedelta(hours=4)
po11 = await create_purchase_order(
db, tenant_id, supplier_high_trust,
PurchaseOrderStatus.sent_to_supplier,
Decimal("850.00"),
created_offset_days=-5,
items_data=[
{"name": "Harina de Trigo T55", "quantity": 500, "unit_price": 0.85, "uom": "kg"},
{"name": "Mantequilla sin Sal 82% MG", "quantity": 50, "unit_price": 6.50, "uom": "kg"}
]
)
# Override delivery date to be 4 hours ago (overdue)
po11.required_delivery_date = delivery_overdue_time
po11.expected_delivery_date = delivery_overdue_time
po11.notes = "🔴 OVERDUE: Expected delivery was 4 hours ago - Contact supplier immediately"
pos_created.append(po11)
# 12. DELIVERY ARRIVING SOON - Arriving in 8 hours (TODAY dashboard alert)
arriving_soon_time = datetime.now(timezone.utc) + timedelta(hours=8)
po12 = await create_purchase_order(
db, tenant_id, supplier_medium_trust,
PurchaseOrderStatus.sent_to_supplier,
Decimal("675.50"),
created_offset_days=-2,
items_data=[
{"name": "Azúcar Moreno", "quantity": 100, "unit_price": 1.80, "uom": "kg"},
{"name": "Aceite de Oliva Virgen", "quantity": 50, "unit_price": 8.50, "uom": "l"},
{"name": "Miel de Azahar", "quantity": 15, "unit_price": 8.90, "uom": "kg"}
]
)
# Override delivery date to be in 8 hours
po12.expected_delivery_date = arriving_soon_time
po12.required_delivery_date = arriving_soon_time
po12.notes = "📦 ARRIVING SOON: Delivery expected in 8 hours - Prepare for stock receipt"
pos_created.append(po12)
await db.commit()
logger.info(
f"Successfully created {len(pos_created)} purchase orders for tenant",
tenant_id=str(tenant_id),
pending_approval=3,
pending_approval=4, # Updated count (includes escalated PO)
approved=2,
completed=2,
sent_to_supplier=2, # Overdue + arriving soon
cancelled=1,
disputed=1
disputed=1,
dashboard_showcase=3 # New POs specifically for dashboard alerts
)
return pos_created

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
Script to emit alerts for existing pending purchase orders.
This is a one-time migration script to create alerts for POs that were
created before the alert emission feature was implemented.
"""
import asyncio
import sys
import os
from pathlib import Path
# Add parent directories to path
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'shared'))
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
import structlog
from app.models.purchase_order import PurchaseOrder
from shared.messaging.rabbitmq import RabbitMQClient
from app.core.config import settings
logger = structlog.get_logger()
async def main():
"""Emit alerts for all pending purchase orders"""
# Create database engine
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Create RabbitMQ client
rabbitmq_client = RabbitMQClient(settings.RABBITMQ_URL, "procurement")
await rabbitmq_client.connect()
try:
async with async_session() as session:
# Get all pending approval POs
query = select(PurchaseOrder).where(
PurchaseOrder.status == 'pending_approval'
)
result = await session.execute(query)
pending_pos = result.scalars().all()
logger.info(f"Found {len(pending_pos)} pending purchase orders")
for po in pending_pos:
try:
# Get supplier info from suppliers service (simplified - using stored data)
# In production, you'd fetch from suppliers service
supplier_name = f"Supplier-{po.supplier_id}"
# Prepare alert payload
from uuid import uuid4
from datetime import datetime as dt, timedelta, timezone
# Get current UTC time with timezone
now_utc = dt.now(timezone.utc)
# Calculate deadline and urgency for priority scoring
if po.required_delivery_date:
# Ensure deadline is timezone-aware
deadline = po.required_delivery_date
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
else:
# Default: 7 days for normal priority, 3 days for critical
days_until = 3 if po.priority == 'critical' else 7
deadline = now_utc + timedelta(days=days_until)
# Calculate hours until consequence (for urgency scoring)
hours_until = (deadline - now_utc).total_seconds() / 3600
alert_data = {
'id': str(uuid4()), # Generate unique alert ID
'tenant_id': str(po.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 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': supplier_name,
'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(),
# Enrichment metadata for priority scoring
'financial_impact': float(po.total_amount), # Business impact
'deadline': deadline.isoformat(), # Urgency deadline
'hours_until_consequence': int(hours_until), # Urgency hours
},
'actions': [
{
'action_type': 'approve_po',
'label': 'Approve PO',
'variant': 'primary',
'disabled': False,
'endpoint': f'/api/v1/tenants/{po.tenant_id}/purchase-orders/{po.id}/approve',
'method': 'POST'
},
{
'action_type': 'reject_po',
'label': 'Reject',
'variant': 'ghost',
'disabled': False,
'endpoint': f'/api/v1/tenants/{po.tenant_id}/purchase-orders/{po.id}/reject',
'method': 'POST'
},
{
'action_type': 'modify_po',
'label': 'Modify',
'variant': 'ghost',
'disabled': False,
'endpoint': f'/api/v1/tenants/{po.tenant_id}/purchase-orders/{po.id}',
'method': 'GET'
}
],
'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:
logger.info(
f"✓ Alert emitted for PO {po.po_number}",
po_id=str(po.id),
tenant_id=str(po.tenant_id)
)
else:
logger.error(
f"✗ Failed to emit alert for PO {po.po_number}",
po_id=str(po.id)
)
# Small delay to avoid overwhelming the system
await asyncio.sleep(0.5)
except Exception as e:
import traceback
logger.error(
f"Error processing PO {po.po_number}",
error=str(e),
po_id=str(po.id),
traceback=traceback.format_exc()
)
continue
logger.info(f"✅ Finished emitting alerts for {len(pending_pos)} purchase orders")
finally:
await rabbitmq_client.disconnect()
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())