Add POI feature and imporve the overall backend implementation
This commit is contained in:
266
services/procurement/app/services/overdue_po_detector.py
Normal file
266
services/procurement/app/services/overdue_po_detector.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
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
|
||||
@@ -31,6 +31,8 @@ from app.schemas.purchase_order_schemas import (
|
||||
from app.core.config import settings
|
||||
from shared.clients.suppliers_client import SuppliersServiceClient
|
||||
from shared.config.base import BaseServiceSettings
|
||||
from shared.messaging.rabbitmq import RabbitMQClient
|
||||
from app.messaging.event_publisher import ProcurementEventPublisher
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -42,7 +44,9 @@ class PurchaseOrderService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
config: BaseServiceSettings,
|
||||
suppliers_client: Optional[SuppliersServiceClient] = None
|
||||
suppliers_client: Optional[SuppliersServiceClient] = None,
|
||||
rabbitmq_client: Optional[RabbitMQClient] = None,
|
||||
event_publisher: Optional[ProcurementEventPublisher] = None
|
||||
):
|
||||
self.db = db
|
||||
self.config = config
|
||||
@@ -54,6 +58,10 @@ class PurchaseOrderService:
|
||||
# Initialize suppliers client for supplier validation
|
||||
self.suppliers_client = suppliers_client or SuppliersServiceClient(config)
|
||||
|
||||
# Initialize event publisher for RabbitMQ events
|
||||
self.rabbitmq_client = rabbitmq_client
|
||||
self.event_publisher = event_publisher or ProcurementEventPublisher(rabbitmq_client)
|
||||
|
||||
# ================================================================
|
||||
# PURCHASE ORDER CRUD
|
||||
# ================================================================
|
||||
@@ -311,7 +319,7 @@ class PurchaseOrderService:
|
||||
approved_by: uuid.UUID,
|
||||
approval_notes: Optional[str] = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Approve a purchase order"""
|
||||
"""Approve a purchase order and publish approval event"""
|
||||
try:
|
||||
logger.info("Approving purchase order", po_id=po_id)
|
||||
|
||||
@@ -322,18 +330,67 @@ class PurchaseOrderService:
|
||||
if po.status not in ['draft', 'pending_approval']:
|
||||
raise ValueError(f"Cannot approve order with status {po.status}")
|
||||
|
||||
# Get supplier details for event and delivery calculation
|
||||
supplier = await self._get_and_validate_supplier(tenant_id, po.supplier_id)
|
||||
|
||||
# Calculate estimated delivery date based on supplier lead time
|
||||
approved_at = datetime.utcnow()
|
||||
standard_lead_time = supplier.get('standard_lead_time', 7) # Default 7 days
|
||||
estimated_delivery_date = approved_at + timedelta(days=standard_lead_time)
|
||||
|
||||
update_data = {
|
||||
'status': 'approved',
|
||||
'approved_by': approved_by,
|
||||
'approved_at': datetime.utcnow(),
|
||||
'approved_at': approved_at,
|
||||
'estimated_delivery_date': estimated_delivery_date,
|
||||
'updated_by': approved_by,
|
||||
'updated_at': datetime.utcnow()
|
||||
'updated_at': approved_at
|
||||
}
|
||||
|
||||
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Purchase order approved successfully", po_id=po_id)
|
||||
|
||||
# Publish PO approved event (non-blocking, fire-and-forget)
|
||||
try:
|
||||
# Get PO items for event
|
||||
items = await self.item_repo.get_items_by_po(po_id)
|
||||
items_data = [
|
||||
{
|
||||
"inventory_product_id": item.inventory_product_id,
|
||||
"product_name": item.product_name or item.product_code,
|
||||
"ordered_quantity": item.ordered_quantity,
|
||||
"unit_of_measure": item.unit_of_measure,
|
||||
"unit_price": item.unit_price,
|
||||
"line_total": item.line_total
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
|
||||
await self.event_publisher.publish_po_approved_event(
|
||||
tenant_id=tenant_id,
|
||||
po_id=po_id,
|
||||
po_number=po.po_number,
|
||||
supplier_id=po.supplier_id,
|
||||
supplier_name=supplier.get('name', ''),
|
||||
supplier_email=supplier.get('email'),
|
||||
supplier_phone=supplier.get('phone'),
|
||||
total_amount=po.total_amount,
|
||||
currency=po.currency,
|
||||
required_delivery_date=po.required_delivery_date.isoformat() if po.required_delivery_date else None,
|
||||
items=items_data,
|
||||
approved_by=approved_by,
|
||||
approved_at=po.approved_at.isoformat()
|
||||
)
|
||||
except Exception as event_error:
|
||||
# Log but don't fail the approval if event publishing fails
|
||||
logger.warning(
|
||||
"Failed to publish PO approved event",
|
||||
po_id=str(po_id),
|
||||
error=str(event_error)
|
||||
)
|
||||
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
@@ -348,7 +405,7 @@ class PurchaseOrderService:
|
||||
rejected_by: uuid.UUID,
|
||||
rejection_reason: str
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Reject a purchase order"""
|
||||
"""Reject a purchase order and publish rejection event"""
|
||||
try:
|
||||
logger.info("Rejecting purchase order", po_id=po_id)
|
||||
|
||||
@@ -359,6 +416,9 @@ class PurchaseOrderService:
|
||||
if po.status not in ['draft', 'pending_approval']:
|
||||
raise ValueError(f"Cannot reject order with status {po.status}")
|
||||
|
||||
# Get supplier details for event
|
||||
supplier = await self._get_and_validate_supplier(tenant_id, po.supplier_id)
|
||||
|
||||
update_data = {
|
||||
'status': 'rejected',
|
||||
'rejection_reason': rejection_reason,
|
||||
@@ -370,6 +430,27 @@ class PurchaseOrderService:
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Purchase order rejected", po_id=po_id)
|
||||
|
||||
# Publish PO rejected event (non-blocking, fire-and-forget)
|
||||
try:
|
||||
await self.event_publisher.publish_po_rejected_event(
|
||||
tenant_id=tenant_id,
|
||||
po_id=po_id,
|
||||
po_number=po.po_number,
|
||||
supplier_id=po.supplier_id,
|
||||
supplier_name=supplier.get('name', ''),
|
||||
rejection_reason=rejection_reason,
|
||||
rejected_by=rejected_by,
|
||||
rejected_at=datetime.utcnow().isoformat()
|
||||
)
|
||||
except Exception as event_error:
|
||||
# Log but don't fail the rejection if event publishing fails
|
||||
logger.warning(
|
||||
"Failed to publish PO rejected event",
|
||||
po_id=str(po_id),
|
||||
error=str(event_error)
|
||||
)
|
||||
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
@@ -485,6 +566,40 @@ class PurchaseOrderService:
|
||||
delivery_id=delivery.id,
|
||||
delivery_number=delivery_number)
|
||||
|
||||
# Publish delivery received event (non-blocking, fire-and-forget)
|
||||
try:
|
||||
# Get all delivery items for the event
|
||||
items_data = []
|
||||
for item_data in delivery_data.items:
|
||||
items_data.append({
|
||||
"inventory_product_id": str(item_data.inventory_product_id),
|
||||
"ordered_quantity": float(item_data.ordered_quantity),
|
||||
"delivered_quantity": float(item_data.delivered_quantity),
|
||||
"accepted_quantity": float(item_data.accepted_quantity),
|
||||
"rejected_quantity": float(item_data.rejected_quantity),
|
||||
"batch_lot_number": item_data.batch_lot_number,
|
||||
"expiry_date": item_data.expiry_date.isoformat() if item_data.expiry_date else None,
|
||||
"quality_grade": item_data.quality_grade,
|
||||
"quality_issues": item_data.quality_issues,
|
||||
"rejection_reason": item_data.rejection_reason
|
||||
})
|
||||
|
||||
await self.event_publisher.publish_delivery_received_event(
|
||||
tenant_id=tenant_id,
|
||||
delivery_id=delivery.id,
|
||||
po_id=delivery_data.purchase_order_id,
|
||||
items=items_data,
|
||||
received_at=datetime.utcnow().isoformat(),
|
||||
received_by=created_by
|
||||
)
|
||||
except Exception as event_error:
|
||||
# Log but don't fail the delivery creation if event publishing fails
|
||||
logger.warning(
|
||||
"Failed to publish delivery received event",
|
||||
delivery_id=str(delivery.id),
|
||||
error=str(event_error)
|
||||
)
|
||||
|
||||
return delivery
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user