""" 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