New alert service
This commit is contained in:
@@ -17,7 +17,12 @@ 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 shared.messaging import RabbitMQClient, UnifiedEventPublisher
|
||||
from sqlalchemy.orm import selectinload
|
||||
from shared.schemas.reasoning_types import (
|
||||
create_po_reasoning_low_stock,
|
||||
create_po_reasoning_supplier_contract
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -265,17 +270,16 @@ 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
|
||||
# For demo sessions: Adjust expected_delivery_date if it exists
|
||||
# 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
|
||||
if hasattr(order, 'expected_delivery_date') and order.expected_delivery_date:
|
||||
# Adjust the existing expected_delivery_date to demo session time
|
||||
expected_delivery = adjust_date_for_demo(
|
||||
order.expected_delivery_date, session_time, BASE_REFERENCE_DATE
|
||||
)
|
||||
elif order.status in ['approved', 'sent_to_supplier', 'confirmed']:
|
||||
# If no expected_delivery_date but order is in delivery status, use estimated_delivery_date
|
||||
expected_delivery = adjusted_estimated_delivery
|
||||
|
||||
# Create new PurchaseOrder - add expected_delivery_date only if column exists (after migration)
|
||||
@@ -433,13 +437,63 @@ async def clone_demo_data(
|
||||
|
||||
total_records = sum(stats.values())
|
||||
|
||||
# FIX DELIVERY ALERT TIMING - Adjust specific POs to guarantee delivery alerts
|
||||
# After cloning, some POs need their expected_delivery_date adjusted relative to session time
|
||||
# to ensure they trigger delivery tracking alerts (arriving soon, overdue, etc.)
|
||||
logger.info("Adjusting delivery PO dates for guaranteed alert triggering")
|
||||
|
||||
# Query for sent_to_supplier POs that have expected_delivery_date
|
||||
result = await db.execute(
|
||||
select(PurchaseOrder)
|
||||
.where(
|
||||
PurchaseOrder.tenant_id == virtual_uuid,
|
||||
PurchaseOrder.status == 'sent_to_supplier',
|
||||
PurchaseOrder.expected_delivery_date.isnot(None)
|
||||
)
|
||||
.limit(5) # Adjust first 5 POs with delivery dates
|
||||
)
|
||||
delivery_pos = result.scalars().all()
|
||||
|
||||
if len(delivery_pos) >= 2:
|
||||
# PO 1: Set to OVERDUE (5 hours ago) - will trigger overdue alert
|
||||
delivery_pos[0].expected_delivery_date = session_time - timedelta(hours=5)
|
||||
delivery_pos[0].required_delivery_date = session_time - timedelta(hours=5)
|
||||
delivery_pos[0].notes = "🔴 OVERDUE: Expected delivery was 5 hours ago - Contact supplier immediately"
|
||||
logger.info(f"Set PO {delivery_pos[0].po_number} to overdue (5 hours ago)")
|
||||
|
||||
# PO 2: Set to ARRIVING SOON (1 hour from now) - will trigger arriving soon alert
|
||||
delivery_pos[1].expected_delivery_date = session_time + timedelta(hours=1)
|
||||
delivery_pos[1].required_delivery_date = session_time + timedelta(hours=1)
|
||||
delivery_pos[1].notes = "📦 ARRIVING SOON: Delivery expected in 1 hour - Prepare for stock receipt"
|
||||
logger.info(f"Set PO {delivery_pos[1].po_number} to arriving soon (1 hour)")
|
||||
|
||||
if len(delivery_pos) >= 4:
|
||||
# PO 3: Set to TODAY AFTERNOON (6 hours from now) - visible in dashboard
|
||||
delivery_pos[2].expected_delivery_date = session_time + timedelta(hours=6)
|
||||
delivery_pos[2].required_delivery_date = session_time + timedelta(hours=6)
|
||||
delivery_pos[2].notes = "📅 TODAY: Delivery scheduled for this afternoon"
|
||||
logger.info(f"Set PO {delivery_pos[2].po_number} to today afternoon (6 hours)")
|
||||
|
||||
# PO 4: Set to TOMORROW MORNING (18 hours from now)
|
||||
delivery_pos[3].expected_delivery_date = session_time + timedelta(hours=18)
|
||||
delivery_pos[3].required_delivery_date = session_time + timedelta(hours=18)
|
||||
delivery_pos[3].notes = "📅 TOMORROW: Morning delivery scheduled"
|
||||
logger.info(f"Set PO {delivery_pos[3].po_number} to tomorrow morning (18 hours)")
|
||||
|
||||
# Commit the adjusted delivery dates
|
||||
await db.commit()
|
||||
logger.info(f"Adjusted {len(delivery_pos)} POs for delivery alert triggering")
|
||||
|
||||
|
||||
# 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(
|
||||
select(PurchaseOrder)
|
||||
.options(selectinload(PurchaseOrder.items))
|
||||
.where(
|
||||
PurchaseOrder.id == order_id,
|
||||
PurchaseOrder.status == 'pending_approval'
|
||||
)
|
||||
@@ -454,12 +508,13 @@ async def clone_demo_data(
|
||||
virtual_tenant_id=virtual_tenant_id
|
||||
)
|
||||
|
||||
# Initialize RabbitMQ client for alert emission
|
||||
# Initialize RabbitMQ client for alert emission using UnifiedEventPublisher
|
||||
alerts_emitted = 0
|
||||
if pending_pos_for_alerts:
|
||||
rabbitmq_client = RabbitMQClient(settings.RABBITMQ_URL, "procurement")
|
||||
try:
|
||||
await rabbitmq_client.connect()
|
||||
event_publisher = UnifiedEventPublisher(rabbitmq_client, "procurement")
|
||||
|
||||
for po in pending_pos_for_alerts:
|
||||
try:
|
||||
@@ -475,42 +530,77 @@ async def clone_demo_data(
|
||||
|
||||
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'
|
||||
# Check for reasoning data and generate if missing
|
||||
reasoning_data = po.reasoning_data
|
||||
|
||||
if not reasoning_data:
|
||||
try:
|
||||
# Generate synthetic reasoning data for demo purposes
|
||||
product_names = [item.product_name for item in po.items] if po.items else ["Assorted Bakery Supplies"]
|
||||
supplier_name = f"Supplier-{str(po.supplier_id)[:8]}" # Fallback name
|
||||
|
||||
# Create realistic looking reasoning based on PO data
|
||||
reasoning_data = create_po_reasoning_low_stock(
|
||||
supplier_name=supplier_name,
|
||||
product_names=product_names,
|
||||
current_stock=15.5, # Simulated
|
||||
required_stock=100.0, # Simulated
|
||||
days_until_stockout=2, # Simulated urgent
|
||||
threshold_percentage=20,
|
||||
affected_products=product_names[:2],
|
||||
estimated_lost_orders=12
|
||||
)
|
||||
logger.info("Generated synthetic reasoning data for demo alert", po_id=str(po.id))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to generate synthetic reasoning data, using ultimate fallback", error=str(e))
|
||||
# Ultimate fallback: Create minimal valid reasoning data structure
|
||||
reasoning_data = {
|
||||
"type": "low_stock_detection",
|
||||
"parameters": {
|
||||
"supplier_name": supplier_name,
|
||||
"product_names": ["Assorted Bakery Supplies"],
|
||||
"product_count": 1,
|
||||
"current_stock": 10.0,
|
||||
"required_stock": 50.0,
|
||||
"days_until_stockout": 2
|
||||
},
|
||||
"consequence": {
|
||||
"type": "stockout_risk",
|
||||
"severity": "medium",
|
||||
"impact_days": 2
|
||||
},
|
||||
"metadata": {
|
||||
"trigger_source": "demo_fallback",
|
||||
"ai_assisted": False
|
||||
}
|
||||
}
|
||||
logger.info("Used ultimate fallback reasoning_data structure", po_id=str(po.id))
|
||||
|
||||
# Prepare metadata for the alert
|
||||
severity = 'high' if po.priority == 'critical' else 'medium'
|
||||
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,
|
||||
'severity': severity,
|
||||
'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': reasoning_data, # For enrichment service
|
||||
}
|
||||
|
||||
# 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
|
||||
# Use UnifiedEventPublisher.publish_alert() which handles MinimalEvent format automatically
|
||||
success = await event_publisher.publish_alert(
|
||||
event_type='supply_chain.po_approval_needed', # domain.event_type format
|
||||
tenant_id=virtual_uuid,
|
||||
severity=severity,
|
||||
data=metadata
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -525,7 +615,8 @@ async def clone_demo_data(
|
||||
logger.error(
|
||||
"Failed to emit PO approval alert during cloning",
|
||||
po_id=str(po.id),
|
||||
error=str(e)
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
# Continue with other POs
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user