New enterprise feature
This commit is contained in:
585
services/distribution/app/services/distribution_service.py
Normal file
585
services/distribution/app/services/distribution_service.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Distribution Service for Enterprise Tier
|
||||
Manages delivery routes and shipment tracking for parent-child tenant networks
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, date, timedelta
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||||
|
||||
from app.models.distribution import DeliveryRoute, Shipment, DeliverySchedule, DeliveryRouteStatus, ShipmentStatus
|
||||
from app.services.routing_optimizer import RoutingOptimizer
|
||||
from shared.clients.tenant_client import TenantServiceClient
|
||||
from shared.clients.inventory_client import InventoryServiceClient
|
||||
from shared.clients.procurement_client import ProcurementServiceClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DistributionService:
|
||||
"""
|
||||
Core business logic for distribution management
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
route_repository,
|
||||
shipment_repository,
|
||||
schedule_repository,
|
||||
procurement_client: ProcurementServiceClient,
|
||||
tenant_client: TenantServiceClient,
|
||||
inventory_client: InventoryServiceClient,
|
||||
routing_optimizer: RoutingOptimizer
|
||||
):
|
||||
self.route_repository = route_repository
|
||||
self.shipment_repository = shipment_repository
|
||||
self.schedule_repository = schedule_repository
|
||||
self.procurement_client = procurement_client
|
||||
self.tenant_client = tenant_client
|
||||
self.inventory_client = inventory_client
|
||||
self.routing_optimizer = routing_optimizer
|
||||
|
||||
async def generate_daily_distribution_plan(
|
||||
self,
|
||||
parent_tenant_id: str,
|
||||
target_date: date,
|
||||
vehicle_capacity_kg: float = 1000.0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate daily distribution plan for internal transfers between parent and children
|
||||
"""
|
||||
logger.info(f"Generating distribution plan for parent tenant {parent_tenant_id} on {target_date}")
|
||||
|
||||
try:
|
||||
# 1. Fetch all approved internal POs for target date from procurement service
|
||||
internal_pos = await self.procurement_client.get_approved_internal_purchase_orders(
|
||||
parent_tenant_id=parent_tenant_id,
|
||||
target_date=target_date
|
||||
)
|
||||
|
||||
if not internal_pos:
|
||||
logger.info(f"No approved internal POs found for {parent_tenant_id} on {target_date}")
|
||||
return {
|
||||
"parent_tenant_id": parent_tenant_id,
|
||||
"target_date": target_date.isoformat(),
|
||||
"routes": [],
|
||||
"shipments": [],
|
||||
"status": "no_deliveries_needed"
|
||||
}
|
||||
|
||||
# 2. Group by child tenant and aggregate weights/volumes
|
||||
deliveries_by_child = {}
|
||||
for po in internal_pos:
|
||||
child_tenant_id = po.get('destination_tenant_id')
|
||||
if child_tenant_id not in deliveries_by_child:
|
||||
deliveries_by_child[child_tenant_id] = {
|
||||
'po_id': po.get('id'),
|
||||
'weight_kg': 0,
|
||||
'volume_m3': 0,
|
||||
'items_count': 0
|
||||
}
|
||||
|
||||
# Calculate total weight and volume for this PO
|
||||
total_weight = 0
|
||||
total_volume = 0
|
||||
for item in po.get('items', []):
|
||||
# In a real implementation, we'd have weight/volume per item
|
||||
# For now, we'll estimate based on quantity
|
||||
quantity = item.get('ordered_quantity', 0)
|
||||
# Typical bakery item weight estimation (adjust as needed)
|
||||
avg_item_weight_kg = 1.0 # Adjust based on actual products
|
||||
total_weight += Decimal(str(quantity)) * Decimal(str(avg_item_weight_kg))
|
||||
|
||||
deliveries_by_child[child_tenant_id]['weight_kg'] += float(total_weight)
|
||||
deliveries_by_child[child_tenant_id]['items_count'] += len(po.get('items', []))
|
||||
|
||||
# 3. Fetch parent depot location and all child locations from tenant service
|
||||
parent_locations_response = await self.tenant_client.get_tenant_locations(parent_tenant_id)
|
||||
parent_locations = parent_locations_response.get("locations", []) if isinstance(parent_locations_response, dict) else parent_locations_response
|
||||
parent_depot = next((loc for loc in parent_locations if loc.get('location_type') == 'central_production'), None)
|
||||
|
||||
if not parent_depot:
|
||||
logger.error(f"No central production location found for parent tenant {parent_tenant_id}")
|
||||
raise ValueError(f"No central production location found for parent tenant {parent_tenant_id}")
|
||||
|
||||
depot_location = (float(parent_depot['latitude']), float(parent_depot['longitude']))
|
||||
|
||||
# Fetch all child tenant locations
|
||||
deliveries_data = []
|
||||
for child_tenant_id, delivery_info in deliveries_by_child.items():
|
||||
child_locations_response = await self.tenant_client.get_tenant_locations(child_tenant_id)
|
||||
child_locations = child_locations_response.get("locations", []) if isinstance(child_locations_response, dict) else child_locations_response
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'retail_outlet'), None)
|
||||
|
||||
if not child_location:
|
||||
logger.warning(f"No retail outlet location found for child tenant {child_tenant_id}")
|
||||
continue
|
||||
|
||||
deliveries_data.append({
|
||||
'id': f"delivery_{child_tenant_id}",
|
||||
'child_tenant_id': child_tenant_id,
|
||||
'location': (float(child_location['latitude']), float(child_location['longitude'])),
|
||||
'weight_kg': delivery_info['weight_kg'],
|
||||
'volume_m3': delivery_info['volume_m3'],
|
||||
'po_id': delivery_info['po_id'],
|
||||
'items_count': delivery_info['items_count']
|
||||
})
|
||||
|
||||
if not deliveries_data:
|
||||
logger.info(f"No valid delivery locations found for distribution plan")
|
||||
return {
|
||||
"parent_tenant_id": parent_tenant_id,
|
||||
"target_date": target_date.isoformat(),
|
||||
"routes": [],
|
||||
"shipments": [],
|
||||
"status": "no_valid_deliveries"
|
||||
}
|
||||
|
||||
# 4. Call routing_optimizer.optimize_daily_routes()
|
||||
optimization_result = await self.routing_optimizer.optimize_daily_routes(
|
||||
deliveries=deliveries_data,
|
||||
depot_location=depot_location,
|
||||
vehicle_capacity_kg=vehicle_capacity_kg
|
||||
)
|
||||
|
||||
# 5. Create DeliveryRoute and Shipment records
|
||||
created_routes = []
|
||||
created_shipments = []
|
||||
|
||||
for route_idx, route_data in enumerate(optimization_result['routes']):
|
||||
# Create DeliveryRoute record
|
||||
route = await self.route_repository.create_route({
|
||||
'tenant_id': parent_tenant_id,
|
||||
'route_number': f"R{target_date.strftime('%Y%m%d')}{route_idx + 1:02d}",
|
||||
'route_date': datetime.combine(target_date, datetime.min.time()),
|
||||
'vehicle_id': route_data.get('vehicle_id'),
|
||||
'driver_id': route_data.get('driver_id'),
|
||||
'total_distance_km': route_data.get('total_distance_km', 0),
|
||||
'estimated_duration_minutes': route_data.get('estimated_duration_minutes', 0),
|
||||
'route_sequence': route_data.get('route_sequence', []),
|
||||
'status': 'planned'
|
||||
})
|
||||
|
||||
created_routes.append(route)
|
||||
|
||||
# Create Shipment records for each stop (excluding depot stops)
|
||||
for stop in route_data.get('route_sequence', []):
|
||||
if stop.get('is_depot', False) == False and 'child_tenant_id' in stop:
|
||||
shipment = await self.shipment_repository.create_shipment({
|
||||
'tenant_id': parent_tenant_id,
|
||||
'parent_tenant_id': parent_tenant_id,
|
||||
'child_tenant_id': stop['child_tenant_id'],
|
||||
'purchase_order_id': stop.get('po_id'),
|
||||
'delivery_route_id': route['id'],
|
||||
'shipment_number': f"S{target_date.strftime('%Y%m%d')}{len(created_shipments) + 1:03d}",
|
||||
'shipment_date': datetime.combine(target_date, datetime.min.time()),
|
||||
'status': 'pending',
|
||||
'total_weight_kg': stop.get('weight_kg', 0),
|
||||
'total_volume_m3': stop.get('volume_m3', 0)
|
||||
})
|
||||
created_shipments.append(shipment)
|
||||
|
||||
logger.info(f"Distribution plan generated: {len(created_routes)} routes, {len(created_shipments)} shipments")
|
||||
|
||||
# 6. Publish distribution.plan.created event to message queue
|
||||
await self._publish_distribution_plan_created_event(
|
||||
parent_tenant_id=parent_tenant_id,
|
||||
target_date=target_date,
|
||||
routes=created_routes,
|
||||
shipments=created_shipments
|
||||
)
|
||||
|
||||
return {
|
||||
"parent_tenant_id": parent_tenant_id,
|
||||
"target_date": target_date.isoformat(),
|
||||
"routes": [route for route in created_routes],
|
||||
"shipments": [shipment for shipment in created_shipments],
|
||||
"optimization_metadata": optimization_result,
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating distribution plan: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def _publish_distribution_plan_created_event(
|
||||
self,
|
||||
parent_tenant_id: str,
|
||||
target_date: date,
|
||||
routes: List[Dict[str, Any]],
|
||||
shipments: List[Dict[str, Any]]
|
||||
):
|
||||
"""
|
||||
Publish distribution plan created event to message queue
|
||||
"""
|
||||
# In a real implementation, this would publish to RabbitMQ
|
||||
logger.info(f"Distribution plan created event published for parent {parent_tenant_id}")
|
||||
|
||||
async def setup_demo_enterprise_distribution(
|
||||
self,
|
||||
parent_tenant_id: str,
|
||||
child_tenant_ids: List[str],
|
||||
session_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Setup distribution routes and schedules for enterprise demo
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Setting up demo distribution for parent {parent_tenant_id} with {len(child_tenant_ids)} children")
|
||||
|
||||
# Get locations for all tenants
|
||||
parent_locations_response = await self.tenant_client.get_tenant_locations(parent_tenant_id)
|
||||
parent_locations = parent_locations_response.get("locations", []) if isinstance(parent_locations_response, dict) else parent_locations_response
|
||||
|
||||
# Look for central production or warehouse location as fallback
|
||||
parent_location = next((loc for loc in parent_locations if loc.get('location_type') == 'central_production'), None)
|
||||
if not parent_location:
|
||||
parent_location = next((loc for loc in parent_locations if loc.get('location_type') == 'warehouse'), None)
|
||||
if not parent_location:
|
||||
parent_location = next((loc for loc in parent_locations if loc.get('name', '').lower().startswith('central')), None)
|
||||
if not parent_location:
|
||||
parent_location = next((loc for loc in parent_locations if loc.get('name', '').lower().startswith('main')), None)
|
||||
|
||||
# If no specific central location found, use first available location
|
||||
if not parent_location and parent_locations:
|
||||
parent_location = parent_locations[0]
|
||||
logger.warning(f"No central production location found for parent tenant {parent_tenant_id}, using first location: {parent_location.get('name', 'unnamed')}")
|
||||
|
||||
if not parent_location:
|
||||
raise ValueError(f"No location found for parent tenant {parent_tenant_id} to use as distribution center")
|
||||
|
||||
# Create delivery schedules for each child
|
||||
for child_id in child_tenant_ids:
|
||||
try:
|
||||
child_locations_response = await self.tenant_client.get_tenant_locations(child_id)
|
||||
child_locations = child_locations_response.get("locations", []) if isinstance(child_locations_response, dict) else child_locations_response
|
||||
|
||||
# Look for retail outlet or store location as first choice
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'retail_outlet'), None)
|
||||
if not child_location:
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'store'), None)
|
||||
if not child_location:
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'branch'), None)
|
||||
|
||||
# If no specific retail location found, use first available location
|
||||
if not child_location and child_locations:
|
||||
child_location = child_locations[0]
|
||||
logger.warning(f"No retail outlet location found for child tenant {child_id}, using first location: {child_location.get('name', 'unnamed')}")
|
||||
|
||||
if not child_location:
|
||||
logger.warning(f"No location found for child tenant {child_id}")
|
||||
continue
|
||||
|
||||
# Create delivery schedule
|
||||
schedule_data = {
|
||||
'parent_tenant_id': parent_tenant_id,
|
||||
'child_tenant_id': child_id,
|
||||
'schedule_name': f"Demo Schedule: {child_location.get('name', f'Child {child_id}')}",
|
||||
'delivery_days': "Mon,Wed,Fri", # Tri-weekly delivery
|
||||
'delivery_time': "09:00", # Morning delivery
|
||||
'auto_generate_orders': True,
|
||||
'lead_time_days': 1,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
# Create the delivery schedule record
|
||||
await self.create_delivery_schedule(schedule_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing child location for {child_id}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
# Create sample delivery route for today
|
||||
today = date.today()
|
||||
delivery_data = []
|
||||
|
||||
# Prepare delivery information for each child
|
||||
for child_id in child_tenant_ids:
|
||||
try:
|
||||
child_locations_response = await self.tenant_client.get_tenant_locations(child_id)
|
||||
child_locations = child_locations_response.get("locations", []) if isinstance(child_locations_response, dict) else child_locations_response
|
||||
|
||||
# Look for retail outlet or store location as first choice
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'retail_outlet'), None)
|
||||
if not child_location:
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'store'), None)
|
||||
if not child_location:
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'branch'), None)
|
||||
|
||||
# If no specific retail location found, use first available location
|
||||
if not child_location and child_locations:
|
||||
child_location = child_locations[0]
|
||||
logger.warning(f"No retail outlet location found for child delivery {child_id}, using first location: {child_location.get('name', 'unnamed')}")
|
||||
|
||||
if child_location:
|
||||
# Ensure we have valid coordinates
|
||||
latitude = child_location.get('latitude')
|
||||
longitude = child_location.get('longitude')
|
||||
|
||||
if latitude is not None and longitude is not None:
|
||||
try:
|
||||
lat = float(latitude)
|
||||
lng = float(longitude)
|
||||
delivery_data.append({
|
||||
'id': f"demo_delivery_{child_id}",
|
||||
'child_tenant_id': child_id,
|
||||
'location': (lat, lng),
|
||||
'weight_kg': 150.0, # Fixed weight for demo
|
||||
'po_id': f"demo_po_{child_id}", # Would be actual PO ID in real implementation
|
||||
'items_count': 20
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Invalid coordinates for child {child_id}, skipping: lat={latitude}, lng={longitude}")
|
||||
else:
|
||||
logger.warning(f"Missing coordinates for child {child_id}, skipping: lat={latitude}, lng={longitude}")
|
||||
else:
|
||||
logger.warning(f"No location found for child delivery {child_id}, skipping")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing child location for {child_id}: {e}", exc_info=True)
|
||||
|
||||
# Optimize routes using VRP - ensure we have valid coordinates
|
||||
parent_latitude = parent_location.get('latitude')
|
||||
parent_longitude = parent_location.get('longitude')
|
||||
|
||||
if parent_latitude is None or parent_longitude is None:
|
||||
logger.error(f"Missing coordinates for parent location {parent_tenant_id}: lat={parent_latitude}, lng={parent_longitude}")
|
||||
raise ValueError(f"Parent location {parent_tenant_id} missing coordinates")
|
||||
|
||||
try:
|
||||
depot_location = (float(parent_latitude), float(parent_longitude))
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"Invalid coordinates for parent location {parent_tenant_id}: lat={parent_latitude}, lng={parent_longitude}, error: {e}")
|
||||
raise ValueError(f"Parent location {parent_tenant_id} has invalid coordinates: {e}")
|
||||
|
||||
optimization_result = await self.routing_optimizer.optimize_daily_routes(
|
||||
deliveries=delivery_data,
|
||||
depot_location=depot_location,
|
||||
vehicle_capacity_kg=1000.0 # Standard vehicle capacity
|
||||
)
|
||||
|
||||
# Create the delivery route for today
|
||||
# Use a random suffix to ensure unique route numbers
|
||||
import secrets
|
||||
unique_suffix = secrets.token_hex(4)[:8]
|
||||
route = await self.route_repository.create_route({
|
||||
'tenant_id': parent_tenant_id,
|
||||
'route_number': f"DEMO-{today.strftime('%Y%m%d')}-{unique_suffix}",
|
||||
'route_date': datetime.combine(today, datetime.min.time()),
|
||||
'total_distance_km': optimization_result.get('total_distance_km', 0),
|
||||
'estimated_duration_minutes': optimization_result.get('estimated_duration_minutes', 0),
|
||||
'route_sequence': optimization_result.get('routes', [])[0].get('route_sequence', []) if optimization_result.get('routes') else [],
|
||||
'status': 'planned'
|
||||
})
|
||||
|
||||
# Create shipment records for each delivery
|
||||
shipments = []
|
||||
for idx, delivery in enumerate(delivery_data):
|
||||
shipment = await self.shipment_repository.create_shipment({
|
||||
'tenant_id': parent_tenant_id,
|
||||
'parent_tenant_id': parent_tenant_id,
|
||||
'child_tenant_id': delivery['child_tenant_id'],
|
||||
'shipment_number': f"DEMOSHP-{today.strftime('%Y%m%d')}-{idx+1:03d}",
|
||||
'shipment_date': datetime.combine(today, datetime.min.time()),
|
||||
'status': 'pending',
|
||||
'total_weight_kg': delivery['weight_kg']
|
||||
})
|
||||
shipments.append(shipment)
|
||||
|
||||
# BUG-012 FIX: Clone historical data from template
|
||||
# Define template tenant IDs (matching seed script)
|
||||
TEMPLATE_PARENT_ID = "c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8"
|
||||
TEMPLATE_CHILD_IDS = [
|
||||
"d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9", # Madrid Centro
|
||||
"e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0", # Barcelona Gràcia
|
||||
"f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1" # Valencia Ruzafa
|
||||
]
|
||||
|
||||
# Create mapping from template child IDs to new session child IDs
|
||||
# Assumption: child_tenant_ids are passed in same order (Madrid, Barcelona, Valencia)
|
||||
child_id_map = {}
|
||||
for idx, template_child_id in enumerate(TEMPLATE_CHILD_IDS):
|
||||
if idx < len(child_tenant_ids):
|
||||
child_id_map[template_child_id] = child_tenant_ids[idx]
|
||||
|
||||
# Calculate date range for history (last 30 days)
|
||||
# Use demo reference date if available in session metadata, otherwise today
|
||||
# Note: session_id is passed, but we need to fetch metadata or infer date
|
||||
# For now, we'll use BASE_REFERENCE_DATE as the anchor, similar to the seed script
|
||||
end_date = BASE_REFERENCE_DATE
|
||||
start_date = end_date - timedelta(days=30)
|
||||
|
||||
logger.info(f"Cloning historical distribution data from {start_date} to {end_date}")
|
||||
|
||||
# Fetch historical routes from template parent
|
||||
historical_routes = await self.route_repository.get_routes_by_date_range(
|
||||
tenant_id=TEMPLATE_PARENT_ID,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
# Fetch historical shipments from template parent
|
||||
historical_shipments = await self.shipment_repository.get_shipments_by_date_range(
|
||||
tenant_id=TEMPLATE_PARENT_ID,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(historical_routes)} routes and {len(historical_shipments)} shipments to clone")
|
||||
|
||||
# Clone routes
|
||||
route_id_map = {} # Old route ID -> New route ID
|
||||
cloned_routes_count = 0
|
||||
|
||||
for route_data in historical_routes:
|
||||
old_route_id = route_data['id']
|
||||
|
||||
# Update route sequence with new child IDs
|
||||
new_sequence = []
|
||||
for stop in route_data.get('route_sequence', []):
|
||||
new_stop = stop.copy()
|
||||
if 'tenant_id' in new_stop and new_stop['tenant_id'] in child_id_map:
|
||||
new_stop['tenant_id'] = child_id_map[new_stop['tenant_id']]
|
||||
new_sequence.append(new_stop)
|
||||
|
||||
# Create new route
|
||||
new_route = await self.route_repository.create_route({
|
||||
'tenant_id': parent_tenant_id,
|
||||
'route_number': route_data['route_number'], # Keep same number for consistency
|
||||
'route_date': route_data['route_date'],
|
||||
'vehicle_id': route_data['vehicle_id'],
|
||||
'driver_id': str(uuid.uuid4()), # New driver
|
||||
'total_distance_km': route_data['total_distance_km'],
|
||||
'estimated_duration_minutes': route_data['estimated_duration_minutes'],
|
||||
'route_sequence': new_sequence,
|
||||
'status': route_data['status']
|
||||
})
|
||||
|
||||
route_id_map[old_route_id] = str(new_route['id'])
|
||||
cloned_routes_count += 1
|
||||
|
||||
# Clone shipments
|
||||
cloned_shipments_count = 0
|
||||
|
||||
for shipment_data in historical_shipments:
|
||||
# Skip if child tenant not in our map (e.g. if we have fewer children than template)
|
||||
if shipment_data['child_tenant_id'] not in child_id_map:
|
||||
continue
|
||||
|
||||
# Map route ID
|
||||
new_route_id = None
|
||||
if shipment_data['delivery_route_id'] in route_id_map:
|
||||
new_route_id = route_id_map[shipment_data['delivery_route_id']]
|
||||
|
||||
# Create new shipment
|
||||
await self.shipment_repository.create_shipment({
|
||||
'tenant_id': parent_tenant_id,
|
||||
'parent_tenant_id': parent_tenant_id,
|
||||
'child_tenant_id': child_id_map[shipment_data['child_tenant_id']],
|
||||
'shipment_number': shipment_data['shipment_number'],
|
||||
'shipment_date': shipment_data['shipment_date'],
|
||||
'status': shipment_data['status'],
|
||||
'total_weight_kg': shipment_data['total_weight_kg'],
|
||||
'total_volume_m3': shipment_data['total_volume_m3'],
|
||||
'delivery_route_id': new_route_id
|
||||
})
|
||||
cloned_shipments_count += 1
|
||||
|
||||
logger.info(f"Demo distribution setup completed: {cloned_routes_count} routes, {cloned_shipments_count} shipments cloned")
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"route_id": None, # No single route ID to return
|
||||
"shipment_count": cloned_shipments_count,
|
||||
"routes_count": cloned_routes_count,
|
||||
"total_distance_km": 0, # Not calculating total for history
|
||||
"session_id": session_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up demo distribution: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def get_delivery_routes_for_date(self, tenant_id: str, target_date: date) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all delivery routes for a specific date and tenant
|
||||
"""
|
||||
routes = await self.route_repository.get_routes_by_date(tenant_id, target_date)
|
||||
return routes
|
||||
|
||||
async def get_shipments_for_date(self, tenant_id: str, target_date: date) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all shipments for a specific date and tenant
|
||||
"""
|
||||
shipments = await self.shipment_repository.get_shipments_by_date(tenant_id, target_date)
|
||||
return shipments
|
||||
|
||||
async def update_shipment_status(self, shipment_id: str, new_status: str, user_id: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Update shipment status with audit trail
|
||||
"""
|
||||
updated_shipment = await self.shipment_repository.update_shipment_status(
|
||||
shipment_id=shipment_id,
|
||||
new_status=new_status,
|
||||
user_id=user_id,
|
||||
metadata=metadata
|
||||
)
|
||||
return updated_shipment
|
||||
|
||||
async def assign_shipments_to_route(self, route_id: str, shipment_ids: List[str], user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Assign multiple shipments to a specific route
|
||||
"""
|
||||
result = await self.shipment_repository.assign_shipments_to_route(
|
||||
route_id=route_id,
|
||||
shipment_ids=shipment_ids,
|
||||
user_id=user_id
|
||||
)
|
||||
return result
|
||||
|
||||
async def create_delivery_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a delivery schedule for recurring deliveries between parent and child tenants
|
||||
|
||||
Args:
|
||||
schedule_data: Dictionary containing schedule information:
|
||||
- parent_tenant_id: UUID of parent tenant
|
||||
- child_tenant_id: UUID of child tenant
|
||||
- schedule_name: Human-readable name for the schedule
|
||||
- delivery_days: Comma-separated days (e.g., "Mon,Wed,Fri")
|
||||
- delivery_time: Time of day for delivery (HH:MM format)
|
||||
- auto_generate_orders: Boolean, whether to auto-generate orders
|
||||
- lead_time_days: Number of days lead time for orders
|
||||
- is_active: Boolean, whether schedule is active
|
||||
|
||||
Returns:
|
||||
Dictionary with created schedule information
|
||||
"""
|
||||
# Create schedule using repository
|
||||
try:
|
||||
# Ensure required fields are present
|
||||
if "delivery_days" not in schedule_data:
|
||||
schedule_data["delivery_days"] = "Mon,Wed,Fri"
|
||||
if "delivery_time" not in schedule_data:
|
||||
schedule_data["delivery_time"] = "09:00"
|
||||
if "auto_generate_orders" not in schedule_data:
|
||||
schedule_data["auto_generate_orders"] = True
|
||||
if "lead_time_days" not in schedule_data:
|
||||
schedule_data["lead_time_days"] = 1
|
||||
if "is_active" not in schedule_data:
|
||||
schedule_data["is_active"] = True
|
||||
|
||||
created_schedule = await self.schedule_repository.create_schedule(schedule_data)
|
||||
|
||||
logger.info(
|
||||
f"Created delivery schedule {created_schedule.id} for parent {schedule_data.get('parent_tenant_id')} "
|
||||
f"to child {schedule_data.get('child_tenant_id')}"
|
||||
)
|
||||
|
||||
return created_schedule
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating delivery schedule: {e}")
|
||||
raise
|
||||
457
services/distribution/app/services/routing_optimizer.py
Normal file
457
services/distribution/app/services/routing_optimizer.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
Routing optimizer for the distribution service using Google OR-Tools VRP
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
|
||||
# Google OR-Tools - Vehicle Routing Problem
|
||||
try:
|
||||
from ortools.constraint_solver import routing_enums_pb2
|
||||
from ortools.constraint_solver import pywrapcp
|
||||
HAS_ORTOOLS = True
|
||||
except ImportError:
|
||||
print("Warning: OR-Tools not installed. Using fallback routing algorithm.")
|
||||
HAS_ORTOOLS = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RoutingOptimizer:
|
||||
"""
|
||||
Vehicle Routing Problem optimizer using Google OR-Tools
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.has_ortools = HAS_ORTOOLS
|
||||
|
||||
async def optimize_daily_routes(
|
||||
self,
|
||||
deliveries: List[Dict[str, Any]],
|
||||
depot_location: Tuple[float, float],
|
||||
vehicle_capacity_kg: Optional[float] = 1000.0,
|
||||
time_limit_seconds: float = 30.0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Optimize daily delivery routes using VRP
|
||||
|
||||
Args:
|
||||
deliveries: List of delivery dictionaries with keys:
|
||||
- id: str - delivery ID
|
||||
- location: Tuple[float, float] - (lat, lng)
|
||||
- weight_kg: float - weight of delivery
|
||||
- time_window: Optional[Tuple[str, str]] - delivery time window
|
||||
depot_location: Tuple[float, float] - depot location (lat, lng)
|
||||
vehicle_capacity_kg: Maximum weight capacity per vehicle
|
||||
time_limit_seconds: Time limit for optimization (timeout)
|
||||
|
||||
Returns:
|
||||
Dict with optimized route sequences and metadata
|
||||
"""
|
||||
if not self.has_ortools:
|
||||
logger.warning("OR-Tools not available, using fallback sequential routing")
|
||||
return self._fallback_sequential_routing(deliveries, depot_location)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Prepare data for VRP
|
||||
locations = [depot_location] # Depot is first location (index 0)
|
||||
demands = [0] # Depot has no demand
|
||||
time_windows = [(0, 24*60)] # Depot available all day (in minutes from midnight)
|
||||
|
||||
delivery_mapping = {}
|
||||
for i, delivery in enumerate(deliveries, 1):
|
||||
locations.append(delivery['location'])
|
||||
# Ensure demands are integers for OR-Tools compatibility
|
||||
weight_kg = delivery.get('weight_kg', 0)
|
||||
demands.append(int(weight_kg) if isinstance(weight_kg, (int, float)) else 0)
|
||||
|
||||
# Convert time windows to minutes from midnight
|
||||
time_window = delivery.get('time_window', None)
|
||||
if time_window:
|
||||
start_time_str, end_time_str = time_window
|
||||
start_minutes = self._time_to_minutes(start_time_str)
|
||||
end_minutes = self._time_to_minutes(end_time_str)
|
||||
time_windows.append((int(start_minutes), int(end_minutes)))
|
||||
else:
|
||||
time_windows.append((0, 24*60)) # Default to all day if no time window
|
||||
|
||||
delivery_mapping[i] = delivery['id']
|
||||
|
||||
# Check if we have no deliveries (only depot), return early with empty route
|
||||
if len(locations) <= 1: # Only depot, no deliveries
|
||||
logger.info("No deliveries to optimize, returning empty route")
|
||||
return {
|
||||
'routes': [],
|
||||
'total_distance_km': 0,
|
||||
'optimization_time_seconds': time.time() - start_time,
|
||||
'algorithm_used': 'ortools_vrp',
|
||||
'status': 'success'
|
||||
}
|
||||
|
||||
# Calculate total demand first before checking it
|
||||
total_demand = sum(demands)
|
||||
|
||||
# Check if total demand is 0 but we have deliveries - handle this case too
|
||||
if total_demand == 0 and len(locations) > 1:
|
||||
logger.info("Total demand is 0 but deliveries exist, returning simple route")
|
||||
# Create simple route with all deliveries but no capacity constraints
|
||||
simple_route = {
|
||||
'route_number': 1,
|
||||
'route_sequence': [delivery_mapping[i] for i in range(1, len(locations))],
|
||||
'stops': [{
|
||||
'stop_number': i,
|
||||
'delivery_id': delivery_mapping.get(i, f"delivery_{i}"),
|
||||
'sequence': i - 1
|
||||
} for i in range(1, len(locations))],
|
||||
'total_weight_kg': 0
|
||||
}
|
||||
return {
|
||||
'routes': [simple_route],
|
||||
'total_distance_km': 0,
|
||||
'optimization_time_seconds': time.time() - start_time,
|
||||
'algorithm_used': 'ortools_vrp_zero_demand',
|
||||
'status': 'success'
|
||||
}
|
||||
|
||||
# Calculate distance matrix using haversine formula
|
||||
distance_matrix = self._calculate_distance_matrix(locations)
|
||||
|
||||
# Create VRP model
|
||||
# Calculate required vehicles (total_demand already calculated above)
|
||||
# Ensure at least 1 vehicle, and enough to cover demand plus buffer
|
||||
min_vehicles = max(1, int(total_demand / vehicle_capacity_kg) + 1)
|
||||
# Add a buffer vehicle just in case
|
||||
num_vehicles = int(min_vehicles + 1)
|
||||
|
||||
logger.info(f"VRP Optimization: Demand={total_demand}kg, Capacity={vehicle_capacity_kg}kg, Vehicles={num_vehicles}")
|
||||
|
||||
# Create VRP model
|
||||
manager = pywrapcp.RoutingIndexManager(
|
||||
len(distance_matrix), # number of locations
|
||||
num_vehicles, # number of vehicles
|
||||
[0] * num_vehicles, # depot index for starts
|
||||
[0] * num_vehicles # depot index for ends
|
||||
)
|
||||
|
||||
routing = pywrapcp.RoutingModel(manager)
|
||||
|
||||
def distance_callback(from_index, to_index):
|
||||
"""Returns the distance between the two nodes."""
|
||||
from_node = manager.IndexToNode(from_index)
|
||||
to_node = manager.IndexToNode(to_index)
|
||||
return distance_matrix[from_node][to_node]
|
||||
|
||||
transit_callback_index = routing.RegisterTransitCallback(distance_callback)
|
||||
routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
|
||||
|
||||
# Add capacity constraint
|
||||
def demand_callback(index):
|
||||
"""Returns the demand of the node."""
|
||||
node = manager.IndexToNode(index)
|
||||
return int(demands[node]) # Ensure demands are integers
|
||||
|
||||
demand_callback_index = routing.RegisterUnaryTransitCallback(demand_callback)
|
||||
routing.AddDimensionWithVehicleCapacity(
|
||||
demand_callback_index,
|
||||
0, # null capacity slack
|
||||
[int(vehicle_capacity_kg)] * num_vehicles, # vehicle maximum capacities (as integers)
|
||||
True, # start cumul to zero
|
||||
'Capacity'
|
||||
)
|
||||
|
||||
# Add time window constraint
|
||||
def time_callback(from_index, to_index):
|
||||
"""Returns the travel time between the two nodes."""
|
||||
from_node = manager.IndexToNode(from_index)
|
||||
to_node = manager.IndexToNode(to_index)
|
||||
# Calculate travel time based on distance (meters) and assumed speed (km/h)
|
||||
distance_m = distance_matrix[from_node][to_node]
|
||||
distance_km = distance_m / 1000.0 # Convert meters to km
|
||||
# Assume 30 km/h average speed for city deliveries
|
||||
travel_time_minutes = (distance_km / 30.0) * 60.0
|
||||
return int(travel_time_minutes)
|
||||
|
||||
time_callback_index = routing.RegisterTransitCallback(time_callback)
|
||||
routing.AddDimension(
|
||||
time_callback_index,
|
||||
60 * 24, # Allow waiting time (24 hours in minutes)
|
||||
60 * 24, # Maximum time per vehicle (24 hours in minutes)
|
||||
False, # Don't force start cumul to zero
|
||||
'Time'
|
||||
)
|
||||
time_dimension = routing.GetDimensionOrDie('Time')
|
||||
|
||||
# Add time window constraints for each location
|
||||
for location_idx in range(len(locations)):
|
||||
index = manager.NodeToIndex(location_idx)
|
||||
if index != -1: # Valid index
|
||||
min_time, max_time = time_windows[location_idx]
|
||||
time_dimension.CumulVar(index).SetRange(int(min_time), int(max_time))
|
||||
|
||||
# Setting first solution heuristic
|
||||
search_parameters = pywrapcp.DefaultRoutingSearchParameters()
|
||||
search_parameters.first_solution_strategy = (
|
||||
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
|
||||
)
|
||||
search_parameters.time_limit.FromSeconds(time_limit_seconds)
|
||||
|
||||
# Solve the problem
|
||||
solution = routing.SolveWithParameters(search_parameters)
|
||||
|
||||
# Check if solution was found
|
||||
if solution:
|
||||
optimized_routes = self._extract_routes(routing, manager, solution, delivery_mapping)
|
||||
|
||||
# Calculate total distance and duration
|
||||
total_distance = 0
|
||||
total_duration = 0
|
||||
for route in optimized_routes:
|
||||
route_distance = 0
|
||||
for stop in route['stops']:
|
||||
route_distance += stop.get('distance_to_next', 0)
|
||||
route['total_distance_km'] = route_distance
|
||||
total_distance += route_distance
|
||||
|
||||
logger.info(f"VRP optimization completed in {time.time() - start_time:.2f}s")
|
||||
|
||||
return {
|
||||
'routes': optimized_routes,
|
||||
'total_distance_km': total_distance,
|
||||
'optimization_time_seconds': time.time() - start_time,
|
||||
'algorithm_used': 'ortools_vrp',
|
||||
'status': 'success'
|
||||
}
|
||||
else:
|
||||
logger.warning("OR-Tools failed to find solution, using fallback routing")
|
||||
return self._fallback_sequential_routing(deliveries, depot_location)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in VRP optimization: {e}")
|
||||
# Fallback to simple sequential routing
|
||||
return self._fallback_sequential_routing(deliveries, depot_location)
|
||||
|
||||
def _calculate_distance_matrix(self, locations: List[Tuple[float, float]]) -> List[List[int]]:
|
||||
"""
|
||||
Calculate distance matrix using haversine formula (in meters)
|
||||
"""
|
||||
import math
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2):
|
||||
"""Calculate distance between two lat/lon points in meters"""
|
||||
R = 6371000 # Earth's radius in meters
|
||||
|
||||
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
||||
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
return R * c # Distance in meters
|
||||
|
||||
n = len(locations)
|
||||
matrix = [[0] * n for _ in range(n)]
|
||||
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j:
|
||||
lat1, lon1 = locations[i]
|
||||
lat2, lon2 = locations[j]
|
||||
dist_m = haversine_distance(lat1, lon1, lat2, lon2)
|
||||
matrix[i][j] = int(dist_m)
|
||||
|
||||
return matrix
|
||||
|
||||
def _extract_routes(self, routing, manager, solution, delivery_mapping) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract routes from OR-Tools solution
|
||||
"""
|
||||
routes = []
|
||||
|
||||
for vehicle_id in range(manager.GetNumberOfVehicles()):
|
||||
index = routing.Start(vehicle_id)
|
||||
|
||||
# Skip if vehicle is not used (Start -> End directly)
|
||||
if routing.IsEnd(solution.Value(routing.NextVar(index))):
|
||||
continue
|
||||
|
||||
current_route = {
|
||||
'route_number': vehicle_id + 1,
|
||||
'stops': [],
|
||||
'total_weight_kg': 0
|
||||
}
|
||||
|
||||
# Initialize route sequence to store the delivery IDs in visit order
|
||||
route_sequence = []
|
||||
|
||||
# Add depot as first stop
|
||||
node_index = manager.IndexToNode(index)
|
||||
delivery_id = delivery_mapping.get(node_index, f"depot_{node_index}")
|
||||
|
||||
current_route['stops'].append({
|
||||
'stop_number': 1,
|
||||
'delivery_id': delivery_id,
|
||||
'location': 'depot',
|
||||
'sequence': 0
|
||||
})
|
||||
|
||||
stop_number = 1
|
||||
|
||||
while not routing.IsEnd(index):
|
||||
index = solution.Value(routing.NextVar(index))
|
||||
node_index = manager.IndexToNode(index)
|
||||
|
||||
if node_index != 0: # Not depot
|
||||
stop_number += 1
|
||||
delivery_id = delivery_mapping.get(node_index, f"delivery_{node_index}")
|
||||
current_route['stops'].append({
|
||||
'stop_number': stop_number,
|
||||
'delivery_id': delivery_id,
|
||||
'location_index': node_index,
|
||||
'sequence': stop_number
|
||||
})
|
||||
|
||||
# Add delivery ID to route sequence (excluding depot stops)
|
||||
route_sequence.append(delivery_id)
|
||||
else: # Back to depot
|
||||
stop_number += 1
|
||||
current_route['stops'].append({
|
||||
'stop_number': stop_number,
|
||||
'delivery_id': f"depot_end_{vehicle_id + 1}",
|
||||
'location': 'depot',
|
||||
'sequence': stop_number
|
||||
})
|
||||
break
|
||||
|
||||
# Add the route_sequence to the current route
|
||||
current_route['route_sequence'] = route_sequence
|
||||
routes.append(current_route)
|
||||
|
||||
return routes
|
||||
|
||||
def _time_to_minutes(self, time_str: str) -> int:
|
||||
"""
|
||||
Convert HH:MM string to minutes from midnight
|
||||
"""
|
||||
if ":" in time_str:
|
||||
hour, minute = map(int, time_str.split(":"))
|
||||
return hour * 60 + minute
|
||||
else:
|
||||
# If it's already in minutes, return as is
|
||||
return int(time_str)
|
||||
|
||||
def _fallback_sequential_routing(self, deliveries: List[Dict[str, Any]], depot_location: Tuple[float, float]) -> Dict[str, Any]:
|
||||
"""
|
||||
Fallback routing algorithm that sequences deliveries sequentially
|
||||
"""
|
||||
import math
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2):
|
||||
"""Calculate distance between two lat/lon points in km"""
|
||||
R = 6371 # Earth's radius in km
|
||||
|
||||
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
||||
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
return R * c # Distance in km
|
||||
|
||||
# Calculate distances from depot to each delivery and between deliveries
|
||||
deliveries_with_distance = []
|
||||
for delivery in deliveries:
|
||||
lat, lon = delivery['location']
|
||||
depot_lat, depot_lon = depot_location
|
||||
dist = haversine_distance(depot_lat, depot_lon, lat, lon)
|
||||
deliveries_with_distance.append({
|
||||
**delivery,
|
||||
'distance_from_depot': dist
|
||||
})
|
||||
|
||||
# Sort deliveries by distance from depot (nearest first)
|
||||
deliveries_with_distance.sort(key=lambda x: x['distance_from_depot'])
|
||||
|
||||
# Create simple route
|
||||
route_stops = []
|
||||
total_distance = 0
|
||||
|
||||
# Start from depot
|
||||
route_stops.append({
|
||||
'stop_number': 1,
|
||||
'delivery_id': 'depot_start',
|
||||
'location': depot_location,
|
||||
'sequence': 0,
|
||||
'is_depot': True
|
||||
})
|
||||
|
||||
# Add deliveries
|
||||
for i, delivery in enumerate(deliveries_with_distance, 1):
|
||||
route_stops.append({
|
||||
'stop_number': i + 1,
|
||||
'delivery_id': delivery['id'],
|
||||
'location': delivery['location'],
|
||||
'weight_kg': delivery.get('weight_kg', 0),
|
||||
'sequence': i,
|
||||
'is_depot': False
|
||||
})
|
||||
|
||||
# Return to depot
|
||||
route_stops.append({
|
||||
'stop_number': len(deliveries_with_distance) + 2,
|
||||
'delivery_id': 'depot_end',
|
||||
'location': depot_location,
|
||||
'sequence': len(deliveries_with_distance) + 1,
|
||||
'is_depot': True
|
||||
})
|
||||
|
||||
# Calculate total distance
|
||||
for i in range(len(route_stops) - 1):
|
||||
current_stop = route_stops[i]
|
||||
next_stop = route_stops[i + 1]
|
||||
|
||||
if not current_stop['is_depot'] or not next_stop['is_depot']:
|
||||
if not current_stop['is_depot'] and not next_stop['is_depot']:
|
||||
# Between two deliveries
|
||||
curr_lat, curr_lon = current_stop['location']
|
||||
next_lat, next_lon = next_stop['location']
|
||||
dist = haversine_distance(curr_lat, curr_lon, next_lat, next_lon)
|
||||
elif current_stop['is_depot'] and not next_stop['is_depot']:
|
||||
# From depot to delivery
|
||||
depot_lat, depot_lon = current_stop['location']
|
||||
del_lat, del_lon = next_stop['location']
|
||||
dist = haversine_distance(depot_lat, depot_lon, del_lat, del_lon)
|
||||
elif not current_stop['is_depot'] and next_stop['is_depot']:
|
||||
# From delivery to depot
|
||||
del_lat, del_lon = current_stop['location']
|
||||
depot_lat, depot_lon = next_stop['location']
|
||||
dist = haversine_distance(del_lat, del_lon, depot_lat, depot_lon)
|
||||
else:
|
||||
dist = 0 # depot to depot
|
||||
|
||||
total_distance += dist
|
||||
route_stops[i]['distance_to_next'] = dist
|
||||
|
||||
# Create route sequence from delivery IDs in the order they appear
|
||||
route_sequence = [stop['delivery_id'] for stop in route_stops if not stop.get('is_depot', False)]
|
||||
|
||||
return {
|
||||
'routes': [{
|
||||
'route_number': 1,
|
||||
'stops': route_stops,
|
||||
'route_sequence': route_sequence,
|
||||
'total_distance_km': total_distance,
|
||||
'total_weight_kg': sum(d.get('weight_kg', 0) for d in deliveries),
|
||||
}],
|
||||
'total_distance_km': total_distance,
|
||||
'optimization_time_seconds': 0,
|
||||
'algorithm_used': 'fallback_sequential',
|
||||
'status': 'success'
|
||||
}
|
||||
Reference in New Issue
Block a user