324 lines
15 KiB
Python
324 lines
15 KiB
Python
"""
|
|
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}")
|
|
|
|
# Legacy setup_demo_enterprise_distribution method removed
|
|
# Distribution now uses standard cloning pattern via /internal/demo/clone endpoint
|
|
|
|
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
|
|
|
|
# VRP Optimization Service Methods
|
|
async def get_route_by_id(self, route_id: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get a specific delivery route by ID
|
|
"""
|
|
return await self.route_repository.get_route_by_id(route_id)
|
|
|
|
async def update_route_vrp_metrics(self, route_id: str, vrp_metrics: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Update VRP optimization metrics for a route
|
|
"""
|
|
return await self.route_repository.update_route_vrp_metrics(route_id, vrp_metrics)
|
|
|
|
async def get_routes_by_tenant(self, tenant_id: str, limit: int = None, offset: int = None, order_by: str = None) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get all routes for a specific tenant with pagination and ordering
|
|
"""
|
|
return await self.route_repository.get_routes_by_tenant(tenant_id, limit, offset, order_by) |