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:

View File

@@ -220,6 +220,7 @@ def upgrade() -> None:
sa.Column('order_date', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('required_delivery_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('estimated_delivery_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('expected_delivery_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('subtotal', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('tax_amount', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('shipping_cost', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0.00'),

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())