New alert service
This commit is contained in:
190
services/procurement/app/api/expected_deliveries.py
Normal file
190
services/procurement/app/api/expected_deliveries.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Expected Deliveries API for Procurement Service
|
||||
Public endpoint for expected delivery tracking
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
import structlog
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List
|
||||
from decimal import Decimal
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing import RouteBuilder
|
||||
|
||||
logger = structlog.get_logger()
|
||||
route_builder = RouteBuilder('procurement')
|
||||
router = APIRouter(tags=["expected-deliveries"])
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("expected-deliveries")
|
||||
)
|
||||
async def get_expected_deliveries(
|
||||
tenant_id: str,
|
||||
days_ahead: int = Query(1, description="Number of days to look ahead", ge=0, le=30),
|
||||
include_overdue: bool = Query(True, description="Include overdue deliveries"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get expected deliveries for delivery tracking system.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID to query
|
||||
days_ahead: Number of days to look ahead (default 1 = today + tomorrow)
|
||||
include_overdue: Include deliveries past expected date (default True)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"deliveries": [
|
||||
{
|
||||
"po_id": "uuid",
|
||||
"po_number": "PO-2025-123",
|
||||
"supplier_id": "uuid",
|
||||
"supplier_name": "Molinos San José",
|
||||
"supplier_phone": "+34 915 234 567",
|
||||
"expected_delivery_date": "2025-12-02T10:00:00Z",
|
||||
"delivery_window_hours": 4,
|
||||
"status": "sent_to_supplier",
|
||||
"line_items": [...],
|
||||
"total_amount": 540.00,
|
||||
"currency": "EUR"
|
||||
}
|
||||
],
|
||||
"total_count": 8
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse tenant_id
|
||||
tenant_uuid = uuid.UUID(tenant_id)
|
||||
|
||||
# Calculate date range
|
||||
now = datetime.now(timezone.utc)
|
||||
end_date = now + timedelta(days=days_ahead)
|
||||
|
||||
logger.info(
|
||||
"Fetching expected deliveries",
|
||||
tenant_id=tenant_id,
|
||||
days_ahead=days_ahead,
|
||||
include_overdue=include_overdue
|
||||
)
|
||||
|
||||
# Build query for purchase orders with expected delivery dates
|
||||
query = select(PurchaseOrder).options(
|
||||
selectinload(PurchaseOrder.items)
|
||||
).where(
|
||||
PurchaseOrder.tenant_id == tenant_uuid,
|
||||
PurchaseOrder.expected_delivery_date.isnot(None),
|
||||
PurchaseOrder.status.in_([
|
||||
PurchaseOrderStatus.approved,
|
||||
PurchaseOrderStatus.sent_to_supplier,
|
||||
PurchaseOrderStatus.confirmed
|
||||
])
|
||||
)
|
||||
|
||||
# Add date filters
|
||||
if include_overdue:
|
||||
# Include any delivery from past until end_date
|
||||
query = query.where(
|
||||
PurchaseOrder.expected_delivery_date <= end_date
|
||||
)
|
||||
else:
|
||||
# Only future deliveries within range
|
||||
query = query.where(
|
||||
PurchaseOrder.expected_delivery_date >= now,
|
||||
PurchaseOrder.expected_delivery_date <= end_date
|
||||
)
|
||||
|
||||
# Order by delivery date
|
||||
query = query.order_by(PurchaseOrder.expected_delivery_date.asc())
|
||||
|
||||
# Execute query
|
||||
result = await db.execute(query)
|
||||
purchase_orders = result.scalars().all()
|
||||
|
||||
# Format deliveries for response
|
||||
deliveries = []
|
||||
|
||||
for po in purchase_orders:
|
||||
# Get supplier info from supplier service (for now, use supplier_id)
|
||||
# In production, you'd fetch from supplier service or join if same DB
|
||||
supplier_name = f"Supplier-{str(po.supplier_id)[:8]}"
|
||||
supplier_phone = None
|
||||
|
||||
# Try to get supplier details from notes or metadata
|
||||
# This is a simplified approach - in production you'd query supplier service
|
||||
if po.notes:
|
||||
if "Molinos San José" in po.notes:
|
||||
supplier_name = "Molinos San José S.L."
|
||||
supplier_phone = "+34 915 234 567"
|
||||
elif "Lácteos del Valle" in po.notes:
|
||||
supplier_name = "Lácteos del Valle S.A."
|
||||
supplier_phone = "+34 913 456 789"
|
||||
elif "Chocolates Valor" in po.notes:
|
||||
supplier_name = "Chocolates Valor"
|
||||
supplier_phone = "+34 965 510 062"
|
||||
elif "Suministros Hostelería" in po.notes:
|
||||
supplier_name = "Suministros Hostelería"
|
||||
supplier_phone = "+34 911 234 567"
|
||||
elif "Miel Artesana" in po.notes:
|
||||
supplier_name = "Miel Artesana"
|
||||
supplier_phone = "+34 918 765 432"
|
||||
|
||||
# Format line items (limit to first 5)
|
||||
line_items = []
|
||||
for item in po.items[:5]:
|
||||
line_items.append({
|
||||
"product_name": item.product_name,
|
||||
"quantity": float(item.ordered_quantity) if item.ordered_quantity else 0,
|
||||
"unit": item.unit_of_measure or "unit"
|
||||
})
|
||||
|
||||
# Default delivery window is 4 hours
|
||||
delivery_window_hours = 4
|
||||
|
||||
delivery_dict = {
|
||||
"po_id": str(po.id),
|
||||
"po_number": po.po_number,
|
||||
"supplier_id": str(po.supplier_id),
|
||||
"supplier_name": supplier_name,
|
||||
"supplier_phone": supplier_phone,
|
||||
"expected_delivery_date": po.expected_delivery_date.isoformat(),
|
||||
"delivery_window_hours": delivery_window_hours,
|
||||
"status": po.status.value,
|
||||
"line_items": line_items,
|
||||
"total_amount": float(po.total_amount) if po.total_amount else 0.0,
|
||||
"currency": po.currency
|
||||
}
|
||||
|
||||
deliveries.append(delivery_dict)
|
||||
|
||||
logger.info(
|
||||
"Expected deliveries retrieved",
|
||||
tenant_id=tenant_id,
|
||||
count=len(deliveries)
|
||||
)
|
||||
|
||||
return {
|
||||
"deliveries": deliveries,
|
||||
"total_count": len(deliveries)
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {tenant_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching expected deliveries",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
197
services/procurement/app/api/internal_delivery.py
Normal file
197
services/procurement/app/api/internal_delivery.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Internal Delivery Tracking API for Procurement Service
|
||||
Service-to-service endpoint for expected delivery tracking by orchestrator
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
import structlog
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List
|
||||
from decimal import Decimal
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
if x_internal_api_key != settings.INTERNAL_API_KEY:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/expected-deliveries")
|
||||
async def get_expected_deliveries(
|
||||
tenant_id: str = Query(..., description="Tenant UUID"),
|
||||
days_ahead: int = Query(1, description="Number of days to look ahead", ge=0, le=30),
|
||||
include_overdue: bool = Query(True, description="Include overdue deliveries"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Get expected deliveries for delivery tracking system.
|
||||
|
||||
Called by orchestrator's DeliveryTrackingService to monitor upcoming deliveries
|
||||
and generate delivery alerts (arriving_soon, overdue, receipt_incomplete).
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID to query
|
||||
days_ahead: Number of days to look ahead (default 1 = today + tomorrow)
|
||||
include_overdue: Include deliveries past expected date (default True)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"deliveries": [
|
||||
{
|
||||
"po_id": "uuid",
|
||||
"po_number": "PO-2025-123",
|
||||
"supplier_id": "uuid",
|
||||
"supplier_name": "Molinos San José",
|
||||
"supplier_phone": "+34 915 234 567",
|
||||
"expected_delivery_date": "2025-12-02T10:00:00Z",
|
||||
"delivery_window_hours": 4,
|
||||
"status": "sent_to_supplier",
|
||||
"line_items": [...],
|
||||
"total_amount": 540.00,
|
||||
"currency": "EUR"
|
||||
}
|
||||
],
|
||||
"total_count": 8
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse tenant_id
|
||||
tenant_uuid = uuid.UUID(tenant_id)
|
||||
|
||||
# Calculate date range
|
||||
now = datetime.now(timezone.utc)
|
||||
end_date = now + timedelta(days=days_ahead)
|
||||
|
||||
logger.info(
|
||||
"Fetching expected deliveries",
|
||||
tenant_id=tenant_id,
|
||||
days_ahead=days_ahead,
|
||||
include_overdue=include_overdue
|
||||
)
|
||||
|
||||
# Build query for purchase orders with expected delivery dates
|
||||
query = select(PurchaseOrder).options(
|
||||
selectinload(PurchaseOrder.items)
|
||||
).where(
|
||||
PurchaseOrder.tenant_id == tenant_uuid,
|
||||
PurchaseOrder.expected_delivery_date.isnot(None),
|
||||
PurchaseOrder.status.in_([
|
||||
PurchaseOrderStatus.approved,
|
||||
PurchaseOrderStatus.sent_to_supplier,
|
||||
PurchaseOrderStatus.confirmed
|
||||
])
|
||||
)
|
||||
|
||||
# Add date filters
|
||||
if include_overdue:
|
||||
# Include any delivery from past until end_date
|
||||
query = query.where(
|
||||
PurchaseOrder.expected_delivery_date <= end_date
|
||||
)
|
||||
else:
|
||||
# Only future deliveries within range
|
||||
query = query.where(
|
||||
PurchaseOrder.expected_delivery_date >= now,
|
||||
PurchaseOrder.expected_delivery_date <= end_date
|
||||
)
|
||||
|
||||
# Order by delivery date
|
||||
query = query.order_by(PurchaseOrder.expected_delivery_date.asc())
|
||||
|
||||
# Execute query
|
||||
result = await db.execute(query)
|
||||
purchase_orders = result.scalars().all()
|
||||
|
||||
# Format deliveries for response
|
||||
deliveries = []
|
||||
|
||||
for po in purchase_orders:
|
||||
# Get supplier info from supplier service (for now, use supplier_id)
|
||||
# In production, you'd fetch from supplier service or join if same DB
|
||||
supplier_name = f"Supplier-{str(po.supplier_id)[:8]}"
|
||||
supplier_phone = None
|
||||
|
||||
# Try to get supplier details from notes or metadata
|
||||
# This is a simplified approach - in production you'd query supplier service
|
||||
if po.notes:
|
||||
if "Molinos San José" in po.notes:
|
||||
supplier_name = "Molinos San José S.L."
|
||||
supplier_phone = "+34 915 234 567"
|
||||
elif "Lácteos del Valle" in po.notes:
|
||||
supplier_name = "Lácteos del Valle S.A."
|
||||
supplier_phone = "+34 913 456 789"
|
||||
elif "Chocolates Valor" in po.notes:
|
||||
supplier_name = "Chocolates Valor"
|
||||
supplier_phone = "+34 965 510 062"
|
||||
elif "Suministros Hostelería" in po.notes:
|
||||
supplier_name = "Suministros Hostelería"
|
||||
supplier_phone = "+34 911 234 567"
|
||||
elif "Miel Artesana" in po.notes:
|
||||
supplier_name = "Miel Artesana"
|
||||
supplier_phone = "+34 918 765 432"
|
||||
|
||||
# Format line items (limit to first 5)
|
||||
line_items = []
|
||||
for item in po.items[:5]:
|
||||
line_items.append({
|
||||
"product_name": item.product_name,
|
||||
"quantity": float(item.ordered_quantity) if item.ordered_quantity else 0,
|
||||
"unit": item.unit_of_measure or "unit"
|
||||
})
|
||||
|
||||
# Default delivery window is 4 hours
|
||||
delivery_window_hours = 4
|
||||
|
||||
delivery_dict = {
|
||||
"po_id": str(po.id),
|
||||
"po_number": po.po_number,
|
||||
"supplier_id": str(po.supplier_id),
|
||||
"supplier_name": supplier_name,
|
||||
"supplier_phone": supplier_phone,
|
||||
"expected_delivery_date": po.expected_delivery_date.isoformat(),
|
||||
"delivery_window_hours": delivery_window_hours,
|
||||
"status": po.status.value,
|
||||
"line_items": line_items,
|
||||
"total_amount": float(po.total_amount) if po.total_amount else 0.0,
|
||||
"currency": po.currency
|
||||
}
|
||||
|
||||
deliveries.append(delivery_dict)
|
||||
|
||||
logger.info(
|
||||
"Expected deliveries retrieved",
|
||||
tenant_id=tenant_id,
|
||||
count=len(deliveries)
|
||||
)
|
||||
|
||||
return {
|
||||
"deliveries": deliveries,
|
||||
"total_count": len(deliveries)
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {tenant_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching expected deliveries",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
98
services/procurement/app/api/internal_delivery_tracking.py
Normal file
98
services/procurement/app/api/internal_delivery_tracking.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Internal API for triggering delivery tracking alerts.
|
||||
Used by demo session cloning to generate realistic late delivery alerts.
|
||||
|
||||
Moved from orchestrator service to procurement service (domain ownership).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Path
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/internal/delivery-tracking/trigger/{tenant_id}")
|
||||
async def trigger_delivery_tracking(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID to check deliveries for"),
|
||||
request: Request = None
|
||||
) -> dict:
|
||||
"""
|
||||
Trigger delivery tracking for a specific tenant (internal use only).
|
||||
|
||||
This endpoint is called by the demo session cloning process after POs are seeded
|
||||
to generate realistic delivery alerts (arriving soon, overdue, etc.).
|
||||
|
||||
Security: Protected by X-Internal-Service header check.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID to check deliveries for
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"tenant_id": "uuid",
|
||||
"alerts_generated": 3,
|
||||
"breakdown": {
|
||||
"arriving_soon": 1,
|
||||
"overdue": 1,
|
||||
"receipt_incomplete": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Verify internal service header
|
||||
if not request or request.headers.get("X-Internal-Service") not in ["demo-session", "internal"]:
|
||||
logger.warning("Unauthorized internal API call", tenant_id=str(tenant_id))
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="This endpoint is for internal service use only"
|
||||
)
|
||||
|
||||
# Get delivery tracking service from app state
|
||||
delivery_tracking_service = getattr(request.app.state, 'delivery_tracking_service', None)
|
||||
|
||||
if not delivery_tracking_service:
|
||||
logger.error("Delivery tracking service not initialized")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Delivery tracking service not available"
|
||||
)
|
||||
|
||||
# Trigger delivery tracking for this tenant
|
||||
logger.info("Triggering delivery tracking", tenant_id=str(tenant_id))
|
||||
result = await delivery_tracking_service.check_expected_deliveries(tenant_id)
|
||||
|
||||
logger.info(
|
||||
"Delivery tracking completed",
|
||||
tenant_id=str(tenant_id),
|
||||
alerts_generated=result.get("total_alerts", 0)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tenant_id": str(tenant_id),
|
||||
"alerts_generated": result.get("total_alerts", 0),
|
||||
"breakdown": {
|
||||
"arriving_soon": result.get("arriving_soon", 0),
|
||||
"overdue": result.get("overdue", 0),
|
||||
"receipt_incomplete": result.get("receipt_incomplete", 0)
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error triggering delivery tracking",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to trigger delivery tracking: {str(e)}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas.purchase_order_schemas import (
|
||||
)
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from app.utils.cache import get_cached, set_cached, make_cache_key
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -123,10 +124,11 @@ async def list_purchase_orders(
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
supplier_id: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None),
|
||||
enrich_supplier: bool = Query(default=True, description="Include supplier details (slower)"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
List purchase orders with filters
|
||||
List purchase orders with filters and caching (30s TTL)
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
@@ -134,20 +136,46 @@ async def list_purchase_orders(
|
||||
limit: Maximum number of records to return
|
||||
supplier_id: Filter by supplier ID (optional)
|
||||
status: Filter by status (optional)
|
||||
enrich_supplier: Whether to enrich with supplier data (default: True)
|
||||
|
||||
Returns:
|
||||
List of purchase orders
|
||||
"""
|
||||
try:
|
||||
# PERFORMANCE OPTIMIZATION: Cache even with status filter for dashboard queries
|
||||
# Only skip cache for supplier_id filter and pagination (skip > 0)
|
||||
cache_key = None
|
||||
if skip == 0 and supplier_id is None:
|
||||
cache_key = make_cache_key(
|
||||
"purchase_orders",
|
||||
tenant_id,
|
||||
limit=limit,
|
||||
status=status, # Include status in cache key
|
||||
enrich_supplier=enrich_supplier
|
||||
)
|
||||
cached_result = await get_cached(cache_key)
|
||||
if cached_result is not None:
|
||||
logger.debug("Cache hit for purchase orders", cache_key=cache_key, tenant_id=tenant_id, status=status)
|
||||
return [PurchaseOrderResponse(**po) for po in cached_result]
|
||||
|
||||
# Cache miss - fetch from database
|
||||
pos = await service.list_purchase_orders(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
supplier_id=uuid.UUID(supplier_id) if supplier_id else None,
|
||||
status=status
|
||||
status=status,
|
||||
enrich_supplier=enrich_supplier
|
||||
)
|
||||
|
||||
return [PurchaseOrderResponse.model_validate(po) for po in pos]
|
||||
result = [PurchaseOrderResponse.model_validate(po) for po in pos]
|
||||
|
||||
# PERFORMANCE OPTIMIZATION: Cache the result (20s TTL for purchase orders)
|
||||
if cache_key:
|
||||
await set_cached(cache_key, [po.model_dump() for po in result], ttl=20)
|
||||
logger.debug("Cached purchase orders", cache_key=cache_key, ttl=20, tenant_id=tenant_id, status=status)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error listing purchase orders", error=str(e), tenant_id=tenant_id)
|
||||
|
||||
Reference in New Issue
Block a user