Add POI feature and imporve the overall backend implementation

This commit is contained in:
Urtzi Alfaro
2025-11-12 15:34:10 +01:00
parent e8096cd979
commit 5783c7ed05
173 changed files with 16862 additions and 9078 deletions

View 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

View File

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