New alert system and panel de control page
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
395
services/procurement/app/services/procurement_event_service.py
Normal file
395
services/procurement/app/services/procurement_event_service.py
Normal 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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
174
services/procurement/scripts/emit_pending_po_alerts.py
Normal file
174
services/procurement/scripts/emit_pending_po_alerts.py
Normal 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())
|
||||
Reference in New Issue
Block a user