267 lines
9.0 KiB
Python
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
|