New alert service
This commit is contained in:
@@ -42,6 +42,7 @@ from shared.schemas.reasoning_types import (
|
||||
create_po_reasoning_supplier_contract
|
||||
)
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||||
from shared.messaging import RabbitMQClient
|
||||
|
||||
# Configure logging
|
||||
logger = structlog.get_logger()
|
||||
@@ -350,9 +351,52 @@ async def create_purchase_order(
|
||||
contract_quantity=float(total_amount)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate reasoning_data: {e}")
|
||||
logger.error(f"Failed to generate reasoning_data, falling back to basic reasoning: {e}")
|
||||
logger.exception(e)
|
||||
pass
|
||||
|
||||
# Fallback: Always generate basic reasoning_data to ensure it exists
|
||||
try:
|
||||
# Get product names from items_data as fallback
|
||||
items_list = items_data or []
|
||||
product_names = [item.get('name', item.get('product_name', f"Product {i+1}")) for i, item in enumerate(items_list)]
|
||||
if not product_names:
|
||||
product_names = ["Demo Product"]
|
||||
|
||||
# Create basic low stock reasoning as fallback
|
||||
reasoning_data = create_po_reasoning_low_stock(
|
||||
supplier_name=supplier.name,
|
||||
product_names=product_names,
|
||||
current_stock=25.0, # Default simulated current stock
|
||||
required_stock=100.0, # Default required stock
|
||||
days_until_stockout=3, # Default days until stockout
|
||||
threshold_percentage=20,
|
||||
affected_products=product_names[:2] # First 2 products affected
|
||||
)
|
||||
logger.info("Successfully generated fallback reasoning_data")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"Fallback reasoning generation also failed: {fallback_error}")
|
||||
# Ultimate fallback: Create minimal valid reasoning data structure
|
||||
reasoning_data = {
|
||||
"type": "low_stock_detection",
|
||||
"parameters": {
|
||||
"supplier_name": supplier.name,
|
||||
"product_names": ["Demo Product"],
|
||||
"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")
|
||||
|
||||
# Create PO
|
||||
po = PurchaseOrder(
|
||||
@@ -639,18 +683,123 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
||||
po12.notes = "📦 ARRIVING SOON: Delivery expected in 8 hours - Prepare for stock receipt"
|
||||
pos_created.append(po12)
|
||||
|
||||
# 13. DELIVERY TODAY MORNING - Scheduled for 10 AM today
|
||||
delivery_today_morning = BASE_REFERENCE_DATE.replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
po13 = await create_purchase_order(
|
||||
db, tenant_id, supplier_high_trust,
|
||||
PurchaseOrderStatus.sent_to_supplier,
|
||||
Decimal("625.00"),
|
||||
created_offset_days=-3,
|
||||
items_data=[
|
||||
{"name": "Harina de Trigo T55", "quantity": 500, "unit_price": 0.85, "uom": "kg"},
|
||||
{"name": "Levadura Fresca", "quantity": 25, "unit_price": 8.00, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
po13.expected_delivery_date = delivery_today_morning
|
||||
po13.required_delivery_date = delivery_today_morning
|
||||
po13.notes = "📦 Delivery scheduled for 10 AM - Essential ingredients for morning production"
|
||||
pos_created.append(po13)
|
||||
|
||||
# 14. DELIVERY TODAY AFTERNOON - Scheduled for 3 PM today
|
||||
delivery_today_afternoon = BASE_REFERENCE_DATE.replace(hour=15, minute=0, second=0, microsecond=0)
|
||||
po14 = await create_purchase_order(
|
||||
db, tenant_id, supplier_medium_trust,
|
||||
PurchaseOrderStatus.confirmed,
|
||||
Decimal("380.50"),
|
||||
created_offset_days=-2,
|
||||
items_data=[
|
||||
{"name": "Papel Kraft Bolsas", "quantity": 5000, "unit_price": 0.05, "uom": "unit"},
|
||||
{"name": "Cajas Pastelería", "quantity": 500, "unit_price": 0.26, "uom": "unit"}
|
||||
]
|
||||
)
|
||||
po14.expected_delivery_date = delivery_today_afternoon
|
||||
po14.required_delivery_date = delivery_today_afternoon
|
||||
po14.notes = "📦 Packaging delivery expected at 3 PM"
|
||||
pos_created.append(po14)
|
||||
|
||||
# 15. DELIVERY TOMORROW EARLY - Scheduled for 8 AM tomorrow (high priority)
|
||||
delivery_tomorrow_early = BASE_REFERENCE_DATE + timedelta(days=1, hours=8)
|
||||
po15 = await create_purchase_order(
|
||||
db, tenant_id, supplier_high_trust,
|
||||
PurchaseOrderStatus.approved,
|
||||
Decimal("445.00"),
|
||||
created_offset_days=-1,
|
||||
items_data=[
|
||||
{"name": "Harina Integral", "quantity": 300, "unit_price": 0.95, "uom": "kg"},
|
||||
{"name": "Sal Marina", "quantity": 50, "unit_price": 1.60, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
po15.expected_delivery_date = delivery_tomorrow_early
|
||||
po15.required_delivery_date = delivery_tomorrow_early
|
||||
po15.priority = "high"
|
||||
po15.notes = "🔔 Critical delivery for weekend production - Confirm with supplier"
|
||||
pos_created.append(po15)
|
||||
|
||||
# 16. DELIVERY TOMORROW LATE - Scheduled for 5 PM tomorrow
|
||||
delivery_tomorrow_late = BASE_REFERENCE_DATE + timedelta(days=1, hours=17)
|
||||
po16 = await create_purchase_order(
|
||||
db, tenant_id, supplier_low_trust,
|
||||
PurchaseOrderStatus.sent_to_supplier,
|
||||
Decimal("890.00"),
|
||||
created_offset_days=-2,
|
||||
items_data=[
|
||||
{"name": "Chocolate Negro 70%", "quantity": 80, "unit_price": 8.50, "uom": "kg"},
|
||||
{"name": "Cacao en Polvo", "quantity": 30, "unit_price": 7.00, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
po16.expected_delivery_date = delivery_tomorrow_late
|
||||
po16.required_delivery_date = delivery_tomorrow_late
|
||||
po16.notes = "📦 Specialty ingredients for chocolate products"
|
||||
pos_created.append(po16)
|
||||
|
||||
# 17. DELIVERY DAY AFTER - Scheduled for 11 AM in 2 days
|
||||
delivery_day_after = BASE_REFERENCE_DATE + timedelta(days=2, hours=11)
|
||||
po17 = await create_purchase_order(
|
||||
db, tenant_id, supplier_medium_trust,
|
||||
PurchaseOrderStatus.confirmed,
|
||||
Decimal("520.00"),
|
||||
created_offset_days=-1,
|
||||
items_data=[
|
||||
{"name": "Nata 35% MG", "quantity": 100, "unit_price": 3.80, "uom": "l"},
|
||||
{"name": "Queso Crema", "quantity": 40, "unit_price": 3.50, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
po17.expected_delivery_date = delivery_day_after
|
||||
po17.required_delivery_date = delivery_day_after
|
||||
po17.notes = "📦 Dairy delivery for mid-week production"
|
||||
pos_created.append(po17)
|
||||
|
||||
# 18. DELIVERY THIS WEEK - Scheduled for 2 PM in 4 days
|
||||
delivery_this_week = BASE_REFERENCE_DATE + timedelta(days=4, hours=14)
|
||||
po18 = await create_purchase_order(
|
||||
db, tenant_id, supplier_low_trust,
|
||||
PurchaseOrderStatus.approved,
|
||||
Decimal("675.50"),
|
||||
created_offset_days=-1,
|
||||
items_data=[
|
||||
{"name": "Miel de Azahar", "quantity": 50, "unit_price": 8.90, "uom": "kg"},
|
||||
{"name": "Almendras Marcona", "quantity": 40, "unit_price": 9.50, "uom": "kg"},
|
||||
{"name": "Nueces", "quantity": 30, "unit_price": 7.20, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
po18.expected_delivery_date = delivery_this_week
|
||||
po18.required_delivery_date = delivery_this_week
|
||||
po18.notes = "📦 Specialty items for artisan products"
|
||||
pos_created.append(po18)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Successfully created {len(pos_created)} purchase orders for tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
pending_approval=4, # Updated count (includes escalated PO)
|
||||
approved=2,
|
||||
approved=3, # PO #15, #18 + 1 regular
|
||||
completed=2,
|
||||
sent_to_supplier=2, # Overdue + arriving soon
|
||||
sent_to_supplier=4, # PO #11, #12, #13, #16
|
||||
confirmed=3, # PO #14, #17 + 1 regular
|
||||
cancelled=1,
|
||||
disputed=1,
|
||||
dashboard_showcase=3 # New POs specifically for dashboard alerts
|
||||
delivery_showcase=9 # POs #11-18 with delivery tracking
|
||||
)
|
||||
|
||||
return pos_created
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/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