Files
bakery-ia/services/procurement/app/services/overdue_po_detector.py

267 lines
9.0 KiB
Python

"""
Overdue Purchase Order Detector
Detects POs that are past their estimated delivery date and triggers alerts.
"""
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional
import structlog
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.purchase_order import PurchaseOrder, PurchaseOrderStatus
from app.core.database import database_manager
logger = structlog.get_logger()
class OverduePODetector:
"""
Detects and reports overdue purchase orders.
A PO is considered overdue if:
- Status is 'approved' or 'sent_to_supplier' (not yet delivered)
- estimated_delivery_date is in the past
- Has not been marked as completed or cancelled
"""
def __init__(self):
"""Initialize overdue PO detector"""
self.overdue_threshold_hours = 24 # Grace period before marking overdue
async def detect_overdue_pos(
self,
tenant_id: Optional[uuid.UUID] = None
) -> List[Dict[str, Any]]:
"""
Detect all overdue POs.
Args:
tenant_id: Optional tenant filter. If None, checks all tenants.
Returns:
List of overdue PO summaries
"""
try:
now = datetime.now(timezone.utc)
async with database_manager.get_session() as session:
# Build query for overdue POs
query = select(PurchaseOrder).where(
and_(
# Only check POs that are in-flight (approved or sent to supplier)
PurchaseOrder.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed
]),
# Must have an estimated delivery date
PurchaseOrder.estimated_delivery_date.isnot(None),
# Delivery date is in the past
PurchaseOrder.estimated_delivery_date < now
)
)
# Add tenant filter if provided
if tenant_id:
query = query.where(PurchaseOrder.tenant_id == tenant_id)
result = await session.execute(query)
overdue_pos = result.scalars().all()
# Calculate days overdue for each PO
overdue_summaries = []
for po in overdue_pos:
days_overdue = (now - po.estimated_delivery_date).days
hours_overdue = (now - po.estimated_delivery_date).total_seconds() / 3600
overdue_summaries.append({
'po_id': str(po.id),
'tenant_id': str(po.tenant_id),
'po_number': po.po_number,
'supplier_id': str(po.supplier_id),
'status': po.status.value,
'total_amount': float(po.total_amount),
'currency': po.currency,
'approved_at': po.approved_at.isoformat() if po.approved_at else None,
'estimated_delivery_date': po.estimated_delivery_date.isoformat(),
'days_overdue': days_overdue,
'hours_overdue': round(hours_overdue, 1),
'severity': self._calculate_severity(days_overdue),
'priority': po.priority
})
if overdue_summaries:
logger.warning(
"Detected overdue purchase orders",
count=len(overdue_summaries),
tenant_id=str(tenant_id) if tenant_id else "all"
)
else:
logger.info(
"No overdue purchase orders detected",
tenant_id=str(tenant_id) if tenant_id else "all"
)
return overdue_summaries
except Exception as e:
logger.error(
"Error detecting overdue POs",
error=str(e),
tenant_id=str(tenant_id) if tenant_id else "all",
exc_info=True
)
return []
async def get_overdue_count_by_tenant(self) -> Dict[str, int]:
"""
Get count of overdue POs grouped by tenant.
Returns:
Dictionary mapping tenant_id to overdue count
"""
try:
now = datetime.now(timezone.utc)
async with database_manager.get_session() as session:
query = select(
PurchaseOrder.tenant_id,
PurchaseOrder.id
).where(
and_(
PurchaseOrder.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed
]),
PurchaseOrder.estimated_delivery_date.isnot(None),
PurchaseOrder.estimated_delivery_date < now
)
)
result = await session.execute(query)
rows = result.all()
# Count by tenant
tenant_counts: Dict[str, int] = {}
for tenant_id, _ in rows:
tenant_id_str = str(tenant_id)
tenant_counts[tenant_id_str] = tenant_counts.get(tenant_id_str, 0) + 1
return tenant_counts
except Exception as e:
logger.error("Error getting overdue counts", error=str(e), exc_info=True)
return {}
def _calculate_severity(self, days_overdue: int) -> str:
"""
Calculate severity level based on days overdue.
Args:
days_overdue: Number of days past delivery date
Returns:
Severity level: 'low', 'medium', 'high', 'critical'
"""
if days_overdue <= 1:
return 'low'
elif days_overdue <= 3:
return 'medium'
elif days_overdue <= 7:
return 'high'
else:
return 'critical'
async def get_overdue_pos_for_dashboard(
self,
tenant_id: uuid.UUID,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Get overdue POs formatted for dashboard display.
Args:
tenant_id: Tenant ID
limit: Max number of results
Returns:
List of overdue POs with dashboard-friendly format
"""
overdue_pos = await self.detect_overdue_pos(tenant_id)
# Sort by severity and days overdue (most critical first)
severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
overdue_pos.sort(
key=lambda x: (severity_order.get(x['severity'], 999), -x['days_overdue'])
)
# Limit results
return overdue_pos[:limit]
async def check_single_po_overdue(
self,
po_id: uuid.UUID,
tenant_id: uuid.UUID
) -> Optional[Dict[str, Any]]:
"""
Check if a single PO is overdue.
Args:
po_id: PO ID
tenant_id: Tenant ID
Returns:
Overdue info if PO is overdue, None otherwise
"""
try:
now = datetime.now(timezone.utc)
async with database_manager.get_session() as session:
query = select(PurchaseOrder).where(
and_(
PurchaseOrder.id == po_id,
PurchaseOrder.tenant_id == tenant_id
)
)
result = await session.execute(query)
po = result.scalar_one_or_none()
if not po:
return None
# Check if overdue
if (
po.status in [
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed
] and
po.estimated_delivery_date and
po.estimated_delivery_date < now
):
days_overdue = (now - po.estimated_delivery_date).days
return {
'po_id': str(po.id),
'po_number': po.po_number,
'days_overdue': days_overdue,
'severity': self._calculate_severity(days_overdue),
'estimated_delivery_date': po.estimated_delivery_date.isoformat()
}
return None
except Exception as e:
logger.error(
"Error checking single PO overdue status",
error=str(e),
po_id=str(po_id),
exc_info=True
)
return None