560 lines
21 KiB
Python
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 |