New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View 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")

View 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")

View 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)}"
)

View File

@@ -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

View File

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