Files
bakery-ia/services/procurement/app/services/delivery_tracking_service.py

560 lines
21 KiB
Python

"""
Delivery Tracking Service - With Leader Election
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 for horizontal scaling.
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 - leader election ensures no duplicate alerts.
"""
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._leader_election = None
self._redis_client = None
self._scheduler_started = False
self.instance_id = str(uuid4())[:8] # Short instance ID for logging
async def start(self):
"""Start the delivery tracking scheduler with leader election"""
try:
# Initialize leader election
await self._setup_leader_election()
except Exception as e:
logger.error("Failed to setup leader election, starting in standalone mode",
error=str(e))
# Fallback: start scheduler without leader election
await self._start_scheduler()
async def _setup_leader_election(self):
"""Setup Redis-based leader election for horizontal scaling"""
from shared.leader_election import LeaderElectionService
import redis.asyncio as redis
# Build Redis URL from config
redis_url = getattr(self.config, 'REDIS_URL', None)
if not redis_url:
redis_password = getattr(self.config, 'REDIS_PASSWORD', '')
redis_host = getattr(self.config, 'REDIS_HOST', 'localhost')
redis_port = getattr(self.config, 'REDIS_PORT', 6379)
redis_db = getattr(self.config, 'REDIS_DB', 0)
redis_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}"
self._redis_client = redis.from_url(redis_url, decode_responses=False)
await self._redis_client.ping()
# Create leader election service
self._leader_election = LeaderElectionService(
self._redis_client,
service_name="procurement-delivery-tracking"
)
# Start leader election with callbacks
await self._leader_election.start(
on_become_leader=self._on_become_leader,
on_lose_leader=self._on_lose_leader
)
logger.info("Leader election initialized for delivery tracking",
is_leader=self._leader_election.is_leader,
instance_id=self.instance_id)
async def _on_become_leader(self):
"""Called when this instance becomes the leader"""
logger.info("Became leader for delivery tracking - starting scheduler",
instance_id=self.instance_id)
await self._start_scheduler()
async def _on_lose_leader(self):
"""Called when this instance loses leadership"""
logger.warning("Lost leadership for delivery tracking - stopping scheduler",
instance_id=self.instance_id)
await self._stop_scheduler()
async def _start_scheduler(self):
"""Start the APScheduler with delivery tracking jobs"""
if self._scheduler_started:
logger.debug("Scheduler already started", instance_id=self.instance_id)
return
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
id='hourly_delivery_check',
name='Hourly Delivery Tracking',
replace_existing=True,
max_instances=1,
coalesce=True
)
self.scheduler.start()
self._scheduler_started = True
next_run = self.scheduler.get_job('hourly_delivery_check').next_run_time
logger.info("Delivery tracking scheduler started",
instance_id=self.instance_id,
next_run=next_run.isoformat() if next_run else None)
async def _stop_scheduler(self):
"""Stop the APScheduler"""
if not self._scheduler_started:
return
if self.scheduler.running:
self.scheduler.shutdown(wait=False)
self._scheduler_started = False
logger.info("Delivery tracking scheduler stopped", instance_id=self.instance_id)
async def stop(self):
"""Stop the scheduler and leader election"""
# Stop leader election first
if self._leader_election:
await self._leader_election.stop()
logger.info("Leader election stopped", instance_id=self.instance_id)
# Stop scheduler
await self._stop_scheduler()
# Close Redis
if self._redis_client:
await self._redis_client.close()
@property
def is_leader(self) -> bool:
"""Check if this instance is the leader"""
return self._leader_election.is_leader if self._leader_election else True
async def _check_all_tenants(self):
"""
Check deliveries for all active tenants.
This method is only called by the leader pod (via APScheduler).
Leader election is handled at the scheduler level, not here.
"""
logger.info("Starting delivery checks", instance_id=self.instance_id)
try:
# 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
)
except Exception as e:
logger.error("Delivery checks failed", error=str(e), exc_info=True)
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