Files
bakery-ia/services/procurement/app/services/delivery_tracking_service.py
2025-12-13 23:57:54 +01:00

508 lines
19 KiB
Python

"""
Delivery Tracking Service - Simplified
Tracks purchase order deliveries and generates appropriate alerts using EventPublisher:
- DELIVERY_ARRIVING_SOON: 2 hours before delivery window
- DELIVERY_OVERDUE: 30 minutes after expected delivery time
- STOCK_RECEIPT_INCOMPLETE: If delivery not marked as received
Runs as internal scheduler with leader election.
Domain ownership: Procurement service owns all PO and delivery tracking.
"""
import structlog
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional, List
from uuid import UUID, uuid4
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.messaging import UnifiedEventPublisher, EVENT_TYPES
from app.models.purchase_order import PurchaseOrder, PurchaseOrderStatus
logger = structlog.get_logger()
class DeliveryTrackingService:
"""
Monitors PO deliveries and generates time-based alerts using EventPublisher.
Uses APScheduler with leader election to run hourly checks.
Only one pod executes checks (others skip if not leader).
"""
def __init__(self, event_publisher: UnifiedEventPublisher, config, database_manager=None):
self.publisher = event_publisher
self.config = config
self.database_manager = database_manager
self.scheduler = AsyncIOScheduler()
self.is_leader = False
self.instance_id = str(uuid4())[:8] # Short instance ID for logging
async def start(self):
"""Start the delivery tracking scheduler"""
# Initialize and start scheduler if not already running
if not self.scheduler.running:
# Add hourly job to check deliveries
self.scheduler.add_job(
self._check_all_tenants,
trigger=CronTrigger(minute=30), # Run every hour at :30 (00:30, 01:30, 02:30, etc.)
id='hourly_delivery_check',
name='Hourly Delivery Tracking',
replace_existing=True,
max_instances=1, # Ensure no overlapping runs
coalesce=True # Combine missed runs
)
self.scheduler.start()
# Log next run time
next_run = self.scheduler.get_job('hourly_delivery_check').next_run_time
logger.info(
"Delivery tracking scheduler started with hourly checks",
instance_id=self.instance_id,
next_run=next_run.isoformat() if next_run else None
)
else:
logger.info(
"Delivery tracking scheduler already running",
instance_id=self.instance_id
)
async def stop(self):
"""Stop the scheduler and release leader lock"""
if self.scheduler.running:
self.scheduler.shutdown(wait=True) # Graceful shutdown
logger.info("Delivery tracking scheduler stopped", instance_id=self.instance_id)
else:
logger.info("Delivery tracking scheduler already stopped", instance_id=self.instance_id)
async def _check_all_tenants(self):
"""
Check deliveries for all active tenants (with leader election).
Only one pod executes this - others skip if not leader.
"""
# Try to acquire leader lock
if not await self._try_acquire_leader_lock():
logger.debug(
"Skipping delivery check - not leader",
instance_id=self.instance_id
)
return
try:
logger.info("Starting delivery checks (as leader)", instance_id=self.instance_id)
# Get all active tenants from database
tenants = await self._get_active_tenants()
total_alerts = 0
for tenant_id in tenants:
try:
result = await self.check_expected_deliveries(tenant_id)
total_alerts += sum(result.values())
except Exception as e:
logger.error(
"Delivery check failed for tenant",
tenant_id=str(tenant_id),
error=str(e),
exc_info=True
)
logger.info(
"Delivery checks completed",
instance_id=self.instance_id,
tenants_checked=len(tenants),
total_alerts=total_alerts
)
finally:
await self._release_leader_lock()
async def _try_acquire_leader_lock(self) -> bool:
"""
Try to acquire leader lock for delivery tracking.
Uses Redis to ensure only one pod runs checks.
Returns True if acquired, False if another pod is leader.
"""
# This simplified version doesn't implement leader election
# In a real implementation, you'd use Redis or database locks
logger.info("Delivery tracking check running", instance_id=self.instance_id)
return True
async def _release_leader_lock(self):
"""Release leader lock"""
logger.debug("Delivery tracking check completed", instance_id=self.instance_id)
async def _get_active_tenants(self) -> List[UUID]:
"""
Get all active tenants from database.
Returns list of tenant UUIDs that have purchase orders.
"""
try:
async with self.database_manager.get_session() as session:
# Get distinct tenant_ids that have purchase orders
query = select(PurchaseOrder.tenant_id).distinct()
result = await session.execute(query)
tenant_ids = [row[0] for row in result.all()]
logger.debug("Active tenants retrieved", count=len(tenant_ids))
return tenant_ids
except Exception as e:
logger.error("Failed to get active tenants", error=str(e))
return []
async def check_expected_deliveries(self, tenant_id: UUID) -> Dict[str, int]:
"""
Check all expected deliveries for a tenant and generate appropriate alerts.
DIRECT DATABASE ACCESS - No API calls needed!
Called by:
- Scheduled job (hourly at :30)
- Manual trigger endpoint (demo cloning)
Returns:
Dict with counts: {
'arriving_soon': int,
'overdue': int,
'receipt_incomplete': int,
'total_alerts': int
}
"""
logger.info("Checking expected deliveries", tenant_id=str(tenant_id))
counts = {
'arriving_soon': 0,
'overdue': 0,
'receipt_incomplete': 0
}
try:
# Get expected deliveries directly from database
deliveries = await self._get_expected_deliveries_from_db(tenant_id)
now = datetime.now(timezone.utc)
for delivery in deliveries:
po_id = delivery.get('po_id')
expected_date = delivery.get('expected_delivery_date')
delivery_window_hours = delivery.get('delivery_window_hours', 4)
status = delivery.get('status')
if not expected_date:
continue
# Parse expected date
if isinstance(expected_date, str):
expected_date = datetime.fromisoformat(expected_date)
# Make timezone-aware
if expected_date.tzinfo is None:
expected_date = expected_date.replace(tzinfo=timezone.utc)
# Calculate delivery window
window_start = expected_date
window_end = expected_date + timedelta(hours=delivery_window_hours)
# Check if arriving soon (2 hours before window)
arriving_soon_time = window_start - timedelta(hours=2)
if arriving_soon_time <= now < window_start and status in ['approved', 'sent_to_supplier']:
if await self._send_arriving_soon_alert(tenant_id, delivery):
counts['arriving_soon'] += 1
# Check if overdue (30 min after window end)
overdue_time = window_end + timedelta(minutes=30)
if now >= overdue_time and status in ['approved', 'sent_to_supplier']:
if await self._send_overdue_alert(tenant_id, delivery):
counts['overdue'] += 1
# Check if receipt incomplete (delivery window passed, not marked received)
if now > window_end and status in ['approved', 'sent_to_supplier']:
if await self._send_receipt_incomplete_alert(tenant_id, delivery):
counts['receipt_incomplete'] += 1
counts['total_alerts'] = sum([counts['arriving_soon'], counts['overdue'], counts['receipt_incomplete']])
logger.info(
"Delivery check completed",
tenant_id=str(tenant_id),
**counts
)
except Exception as e:
logger.error(
"Error checking deliveries",
tenant_id=str(tenant_id),
error=str(e),
exc_info=True
)
return counts
async def _get_expected_deliveries_from_db(
self,
tenant_id: UUID,
days_ahead: int = 1,
include_overdue: bool = True
) -> List[Dict[str, Any]]:
"""
Query expected deliveries DIRECTLY from database (no HTTP call).
This replaces the HTTP call to /api/internal/expected-deliveries.
Returns:
List of delivery dicts with same structure as API endpoint
"""
try:
async with self.database_manager.get_session() as session:
# Calculate date range
now = datetime.now(timezone.utc)
end_date = now + timedelta(days=days_ahead)
# Build query for purchase orders with expected delivery dates
query = select(PurchaseOrder).options(
selectinload(PurchaseOrder.items)
).where(
PurchaseOrder.tenant_id == tenant_id,
PurchaseOrder.expected_delivery_date.isnot(None),
PurchaseOrder.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed
])
)
# Add date filters
if include_overdue:
query = query.where(PurchaseOrder.expected_delivery_date <= end_date)
else:
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 session.execute(query)
purchase_orders = result.scalars().all()
logger.info(
"Expected deliveries query executed",
tenant_id=str(tenant_id),
po_count=len(purchase_orders),
days_ahead=days_ahead,
include_overdue=include_overdue,
now=now.isoformat(),
end_date=end_date.isoformat()
)
# Format deliveries (same structure as API endpoint)
deliveries = []
for po in purchase_orders:
# Simple supplier name extraction
supplier_name = f"Supplier-{str(po.supplier_id)[:8]}"
supplier_phone = None
# Extract from notes if available
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"
# Format line items
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"
})
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() if po.expected_delivery_date else None,
"delivery_window_hours": 4, # Default
"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)
return deliveries
except Exception as e:
logger.error(
"Error fetching expected deliveries from database",
tenant_id=str(tenant_id),
error=str(e),
exc_info=True
)
return []
async def _send_arriving_soon_alert(
self,
tenant_id: UUID,
delivery: Dict[str, Any]
) -> bool:
"""
Send DELIVERY_ARRIVING_SOON alert (2h before delivery window).
This appears in the action queue with "Mark as Received" action.
"""
po_number = delivery.get('po_number', 'N/A')
supplier_name = delivery.get('supplier_name', 'Supplier')
expected_date = delivery.get('expected_delivery_date')
line_items = delivery.get('line_items', [])
# Format product list
products = [item['product_name'] for item in line_items[:3]]
product_list = ", ".join(products)
if len(line_items) > 3:
product_list += f" (+{len(line_items) - 3} more)"
# Calculate time until arrival
if isinstance(expected_date, str):
expected_date = datetime.fromisoformat(expected_date)
if expected_date.tzinfo is None:
expected_date = expected_date.replace(tzinfo=timezone.utc)
hours_until = (expected_date - datetime.now(timezone.utc)).total_seconds() / 3600
metadata = {
"po_id": delivery['po_id'],
"po_number": po_number,
"supplier_id": delivery.get('supplier_id'),
"supplier_name": supplier_name,
"supplier_phone": delivery.get('supplier_phone'),
"expected_delivery_date": expected_date.isoformat(),
"line_items": line_items,
"hours_until_arrival": hours_until,
}
# Send alert using UnifiedEventPublisher
success = await self.publisher.publish_alert(
event_type="supply_chain.delivery_arriving_soon",
tenant_id=tenant_id,
severity="medium",
data=metadata
)
if success:
logger.info(
"Sent arriving soon alert",
po_number=po_number,
supplier=supplier_name
)
return success
async def _send_overdue_alert(
self,
tenant_id: UUID,
delivery: Dict[str, Any]
) -> bool:
"""
Send DELIVERY_OVERDUE alert (30min after expected window).
Critical priority - needs immediate action (call supplier).
"""
po_number = delivery.get('po_number', 'N/A')
supplier_name = delivery.get('supplier_name', 'Supplier')
expected_date = delivery.get('expected_delivery_date')
# Calculate how late
if isinstance(expected_date, str):
expected_date = datetime.fromisoformat(expected_date)
if expected_date.tzinfo is None:
expected_date = expected_date.replace(tzinfo=timezone.utc)
hours_late = (datetime.now(timezone.utc) - expected_date).total_seconds() / 3600
metadata = {
"po_id": delivery['po_id'],
"po_number": po_number,
"supplier_id": delivery.get('supplier_id'),
"supplier_name": supplier_name,
"supplier_phone": delivery.get('supplier_phone'),
"expected_delivery_date": expected_date.isoformat(),
"hours_late": hours_late,
"financial_impact": delivery.get('total_amount', 0),
"affected_orders": len(delivery.get('affected_production_batches', [])),
}
# Send alert with high severity
success = await self.publisher.publish_alert(
event_type="supply_chain.delivery_overdue",
tenant_id=tenant_id,
severity="high",
data=metadata
)
if success:
logger.warning(
"Sent overdue delivery alert",
po_number=po_number,
supplier=supplier_name,
hours_late=hours_late
)
return success
async def _send_receipt_incomplete_alert(
self,
tenant_id: UUID,
delivery: Dict[str, Any]
) -> bool:
"""
Send STOCK_RECEIPT_INCOMPLETE alert.
Delivery window has passed but stock not marked as received.
"""
po_number = delivery.get('po_number', 'N/A')
supplier_name = delivery.get('supplier_name', 'Supplier')
metadata = {
"po_id": delivery['po_id'],
"po_number": po_number,
"supplier_id": delivery.get('supplier_id'),
"supplier_name": supplier_name,
"expected_delivery_date": delivery.get('expected_delivery_date'),
}
# Send alert using UnifiedEventPublisher
success = await self.publisher.publish_alert(
event_type="supply_chain.stock_receipt_incomplete",
tenant_id=tenant_id,
severity="medium",
data=metadata
)
if success:
logger.info(
"Sent receipt incomplete alert",
po_number=po_number
)
return success