Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
"""
Dependency Injection for Distribution Service
"""
from typing import AsyncGenerator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.config import settings
from app.repositories.delivery_route_repository import DeliveryRouteRepository
from app.repositories.shipment_repository import ShipmentRepository
from app.repositories.delivery_schedule_repository import DeliveryScheduleRepository
from app.services.distribution_service import DistributionService
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
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Get database session dependency"""
async for session in get_db():
yield session
async def get_route_repository(db_session: AsyncSession = Depends(get_db_session)) -> DeliveryRouteRepository:
"""Get delivery route repository dependency"""
return DeliveryRouteRepository(db_session)
async def get_shipment_repository(db_session: AsyncSession = Depends(get_db_session)) -> ShipmentRepository:
"""Get shipment repository dependency"""
return ShipmentRepository(db_session)
async def get_delivery_schedule_repository(db_session: AsyncSession = Depends(get_db_session)) -> DeliveryScheduleRepository:
"""Get delivery schedule repository dependency"""
return DeliveryScheduleRepository(db_session)
def get_tenant_client() -> TenantServiceClient:
"""Get tenant service client dependency"""
return TenantServiceClient(settings)
def get_inventory_client() -> InventoryServiceClient:
"""Get inventory service client dependency"""
return InventoryServiceClient(settings)
def get_procurement_client() -> ProcurementServiceClient:
"""Get procurement service client dependency"""
return ProcurementServiceClient(settings)
def get_routing_optimizer() -> RoutingOptimizer:
"""Get routing optimizer service dependency"""
return RoutingOptimizer()
def get_distribution_service(
route_repository: DeliveryRouteRepository = Depends(get_route_repository),
shipment_repository: ShipmentRepository = Depends(get_shipment_repository),
schedule_repository: DeliveryScheduleRepository = Depends(get_delivery_schedule_repository),
tenant_client: TenantServiceClient = Depends(get_tenant_client),
inventory_client: InventoryServiceClient = Depends(get_inventory_client),
procurement_client: ProcurementServiceClient = Depends(get_procurement_client),
routing_optimizer: RoutingOptimizer = Depends(get_routing_optimizer)
) -> DistributionService:
"""Get distribution service dependency with all required clients"""
return DistributionService(
route_repository=route_repository,
shipment_repository=shipment_repository,
schedule_repository=schedule_repository,
tenant_client=tenant_client,
inventory_client=inventory_client,
procurement_client=procurement_client,
routing_optimizer=routing_optimizer
)

View File

@@ -0,0 +1,418 @@
"""
Internal Demo Cloning API for Distribution Service
Service-to-service endpoint for cloning distribution data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
import structlog
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional
import os
import json
from pathlib import Path
from app.core.database import get_db
from app.models.distribution import DeliveryRoute, Shipment
from shared.utils.demo_dates import adjust_date_for_demo, resolve_time_marker
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
def parse_date_field(date_value, session_time: datetime, field_name: str = "date") -> Optional[datetime]:
"""
Parse date field, handling both ISO strings and BASE_TS markers.
Supports:
- BASE_TS markers: "BASE_TS + 1h30m", "BASE_TS - 2d"
- ISO 8601 strings: "2025-01-15T06:00:00Z"
- None values (returns None)
Returns timezone-aware datetime or None.
"""
if not date_value:
return None
# Check if it's a BASE_TS marker
if isinstance(date_value, str) and date_value.startswith("BASE_TS"):
try:
return resolve_time_marker(date_value, session_time)
except ValueError as e:
logger.warning(
f"Invalid BASE_TS marker in {field_name}",
marker=date_value,
error=str(e)
)
return None
# Handle regular ISO date strings
try:
if isinstance(date_value, str):
original_date = datetime.fromisoformat(date_value.replace('Z', '+00:00'))
elif hasattr(date_value, 'isoformat'):
original_date = date_value
else:
logger.warning(f"Unsupported date format in {field_name}", date_value=date_value)
return None
return adjust_date_for_demo(original_date, session_time)
except (ValueError, AttributeError) as e:
logger.warning(
f"Invalid date format in {field_name}",
date_value=date_value,
error=str(e)
)
return None
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""
Clone distribution service data for a virtual demo tenant
Clones:
- Delivery routes
- Shipments
- Adjusts dates to recent timeframe
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting distribution data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Track cloning statistics
stats = {
"delivery_routes": 0,
"shipments": 0,
"alerts_generated": 0
}
# Load seed data from JSON files
from shared.utils.seed_data_paths import get_seed_data_path
if demo_account_type == "professional":
json_file = get_seed_data_path("professional", "12-distribution.json")
elif demo_account_type == "enterprise":
json_file = get_seed_data_path("enterprise", "12-distribution.json")
elif demo_account_type == "enterprise_child":
# Child outlets don't have their own distribution data
# Distribution is managed centrally by the parent tenant
# Child locations are delivery destinations, not distribution hubs
logger.info(
"Skipping distribution cloning for child outlet - distribution managed by parent",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
session_id=session_id
)
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
return {
"service": "distribution",
"status": "completed",
"records_cloned": 0,
"duration_ms": duration_ms,
"details": {
"note": "Child outlets don't manage distribution - handled by parent tenant"
}
}
else:
raise ValueError(f"Invalid demo account type: {demo_account_type}")
# Load JSON data
with open(json_file, 'r', encoding='utf-8') as f:
seed_data = json.load(f)
logger.info(
"Loaded distribution seed data",
delivery_routes=len(seed_data.get('delivery_routes', [])),
shipments=len(seed_data.get('shipments', []))
)
# Clone Delivery Routes
for route_data in seed_data.get('delivery_routes', []):
# Transform IDs using XOR
from shared.utils.demo_id_transformer import transform_id
try:
route_uuid = uuid.UUID(route_data['id'])
transformed_id = transform_id(route_data['id'], virtual_uuid)
except ValueError as e:
logger.error("Failed to parse route UUID",
route_id=route_data['id'],
error=str(e))
continue
# Parse date fields
route_date = parse_date_field(
route_data.get('route_date'),
session_time,
"route_date"
) or session_time
# Parse route sequence dates
parsed_sequence = []
for stop in route_data.get('route_sequence', []):
estimated_arrival = parse_date_field(
stop.get('estimated_arrival'),
session_time,
"estimated_arrival"
)
actual_arrival = parse_date_field(
stop.get('actual_arrival'),
session_time,
"actual_arrival"
)
parsed_sequence.append({
**stop,
"estimated_arrival": estimated_arrival.isoformat() if estimated_arrival else None,
"actual_arrival": actual_arrival.isoformat() if actual_arrival else None
})
# Make route_number unique per virtual tenant to prevent conflicts across demo sessions
# Append last 6 chars of virtual_tenant_id to ensure uniqueness
base_route_number = route_data.get('route_number', 'ROUTE-001')
unique_route_number = f"{base_route_number}-{str(virtual_uuid)[-6:]}"
# Create new delivery route
new_route = DeliveryRoute(
id=transformed_id,
tenant_id=virtual_uuid,
route_number=unique_route_number,
route_date=route_date,
vehicle_id=route_data.get('vehicle_id'),
driver_id=route_data.get('driver_id'),
total_distance_km=route_data.get('total_distance_km'),
estimated_duration_minutes=route_data.get('estimated_duration_minutes'),
route_sequence=parsed_sequence,
notes=route_data.get('notes'),
status=route_data.get('status', 'planned'),
created_at=session_time,
updated_at=session_time,
created_by=base_uuid,
updated_by=base_uuid
)
db.add(new_route)
stats["delivery_routes"] += 1
# Clone Shipments
for shipment_data in seed_data.get('shipments', []):
# Transform IDs using XOR
from shared.utils.demo_id_transformer import transform_id
try:
shipment_uuid = uuid.UUID(shipment_data['id'])
transformed_id = transform_id(shipment_data['id'], virtual_uuid)
except ValueError as e:
logger.error("Failed to parse shipment UUID",
shipment_id=shipment_data['id'],
error=str(e))
continue
# Parse date fields
shipment_date = parse_date_field(
shipment_data.get('shipment_date'),
session_time,
"shipment_date"
) or session_time
# Note: The Shipment model doesn't have estimated_delivery_time
# Only actual_delivery_time is stored
actual_delivery_time = parse_date_field(
shipment_data.get('actual_delivery_time'),
session_time,
"actual_delivery_time"
)
# Transform purchase_order_id if present (links to internal transfer PO)
purchase_order_id = None
if shipment_data.get('purchase_order_id'):
try:
po_uuid = uuid.UUID(shipment_data['purchase_order_id'])
purchase_order_id = transform_id(shipment_data['purchase_order_id'], virtual_uuid)
except ValueError:
logger.warning(
"Invalid purchase_order_id format",
purchase_order_id=shipment_data.get('purchase_order_id')
)
# Transform delivery_route_id (CRITICAL: must reference transformed route)
delivery_route_id = None
if shipment_data.get('delivery_route_id'):
try:
route_uuid = uuid.UUID(shipment_data['delivery_route_id'])
delivery_route_id = transform_id(shipment_data['delivery_route_id'], virtual_uuid)
except ValueError:
logger.warning(
"Invalid delivery_route_id format",
delivery_route_id=shipment_data.get('delivery_route_id')
)
# Store items in delivery_notes as JSON for demo purposes
# (In production, items are in the linked purchase order)
items_json = json.dumps(shipment_data.get('items', [])) if shipment_data.get('items') else None
# Make shipment_number unique per virtual tenant to prevent conflicts across demo sessions
# Append last 6 chars of virtual_tenant_id to ensure uniqueness
base_shipment_number = shipment_data.get('shipment_number', 'SHIP-001')
unique_shipment_number = f"{base_shipment_number}-{str(virtual_uuid)[-6:]}"
# Create new shipment
new_shipment = Shipment(
id=transformed_id,
tenant_id=virtual_uuid,
parent_tenant_id=virtual_uuid, # Parent is the same as tenant for demo
child_tenant_id=shipment_data.get('child_tenant_id'),
purchase_order_id=purchase_order_id, # Link to internal transfer PO
delivery_route_id=delivery_route_id, # MUST use transformed ID
shipment_number=unique_shipment_number,
shipment_date=shipment_date,
status=shipment_data.get('status', 'pending'),
total_weight_kg=shipment_data.get('total_weight_kg'),
actual_delivery_time=actual_delivery_time,
# Store items info in delivery_notes for demo display
delivery_notes=f"{shipment_data.get('notes', '')}\nItems: {items_json}" if items_json else shipment_data.get('notes'),
created_at=session_time,
updated_at=session_time,
created_by=base_uuid,
updated_by=base_uuid
)
db.add(new_shipment)
stats["shipments"] += 1
# Commit cloned data
await db.commit()
total_records = stats["delivery_routes"] + stats["shipments"]
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Distribution data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "distribution",
"status": "completed",
"records_cloned": total_records,
"duration_ms": duration_ms,
"details": stats
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone distribution data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "distribution",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check():
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "distribution",
"clone_endpoint": "available",
"version": "1.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
db: AsyncSession = Depends(get_db)
):
"""Delete all distribution data for a virtual demo tenant"""
logger.info("Deleting distribution data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
start_time = datetime.now(timezone.utc)
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Count records
route_count = await db.scalar(select(func.count(DeliveryRoute.id)).where(DeliveryRoute.tenant_id == virtual_uuid))
shipment_count = await db.scalar(select(func.count(Shipment.id)).where(Shipment.tenant_id == virtual_uuid))
# Delete in order
await db.execute(delete(Shipment).where(Shipment.tenant_id == virtual_uuid))
await db.execute(delete(DeliveryRoute).where(DeliveryRoute.tenant_id == virtual_uuid))
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info("Distribution data deleted successfully", virtual_tenant_id=virtual_tenant_id, duration_ms=duration_ms)
return {
"service": "distribution",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"delivery_routes": route_count,
"shipments": shipment_count,
"total": route_count + shipment_count
},
"duration_ms": duration_ms
}
except Exception as e:
logger.error("Failed to delete distribution data", error=str(e), exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,141 @@
"""
API Routes for Distribution Service
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from typing import List, Optional, Dict, Any
from datetime import date, timedelta
import structlog
import os
from app.api.dependencies import get_distribution_service
from shared.auth.tenant_access import verify_tenant_access_dep
from shared.routing.route_builder import RouteBuilder
from app.core.config import settings
logger = structlog.get_logger()
# Initialize route builder for distribution service
route_builder = RouteBuilder('distribution')
# ✅ Security: Internal API key system removed
# All authentication now handled via JWT service tokens at gateway level
router = APIRouter()
@router.post(route_builder.build_base_route("plans/generate"))
async def generate_daily_distribution_plan(
tenant_id: str,
target_date: date = Query(..., description="Date for which to generate distribution plan"),
vehicle_capacity_kg: float = Query(1000.0, description="Vehicle capacity in kg"),
distribution_service: object = Depends(get_distribution_service),
verified_tenant: str = Depends(verify_tenant_access_dep)
):
"""
Generate daily distribution plan for internal transfers
**Enterprise Tier Feature**: Distribution and routing require Enterprise subscription.
"""
try:
# Validate subscription tier for distribution features
from shared.subscription.plans import PlanFeatures
from shared.clients import get_tenant_client
tenant_client = get_tenant_client(config=settings, service_name="distribution-service")
subscription = await tenant_client.get_tenant_subscription(tenant_id)
if not subscription:
raise HTTPException(
status_code=403,
detail="No active subscription found. Distribution routing requires Enterprise tier."
)
# Check if tier has distribution feature (enterprise only)
tier = subscription.get("plan", "starter")
if not PlanFeatures.has_feature(tier, "distribution_management"):
raise HTTPException(
status_code=403,
detail=f"Distribution routing requires Enterprise tier. Current tier: {tier}"
)
result = await distribution_service.generate_daily_distribution_plan(
parent_tenant_id=tenant_id,
target_date=target_date,
vehicle_capacity_kg=vehicle_capacity_kg
)
return result
except HTTPException:
raise
except Exception as e:
logger.error("Error generating distribution plan", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to generate distribution plan: {str(e)}")
@router.get(route_builder.build_base_route("routes"))
async def get_delivery_routes(
tenant_id: str,
date_from: Optional[date] = Query(None, description="Start date for route filtering"),
date_to: Optional[date] = Query(None, description="End date for route filtering"),
status: Optional[str] = Query(None, description="Filter by route status"),
distribution_service: object = Depends(get_distribution_service),
verified_tenant: str = Depends(verify_tenant_access_dep)
):
"""
Get delivery routes with optional filtering
"""
try:
# If no date range specified, default to today
if not date_from and not date_to:
date_from = date.today()
date_to = date.today()
elif not date_to:
date_to = date_from
routes = []
current_date = date_from
while current_date <= date_to:
daily_routes = await distribution_service.get_delivery_routes_for_date(tenant_id, current_date)
routes.extend(daily_routes)
current_date = current_date + timedelta(days=1)
if status:
routes = [r for r in routes if r.get('status') == status]
return {"routes": routes}
except Exception as e:
logger.error("Error getting delivery routes", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get delivery routes: {str(e)}")
@router.get(route_builder.build_base_route("routes/{route_id}"))
async def get_route_detail(
tenant_id: str,
route_id: str,
distribution_service: object = Depends(get_distribution_service),
verified_tenant: str = Depends(verify_tenant_access_dep)
):
"""
Get delivery route details
"""
try:
# Implementation would fetch detailed route information
# For now, return a simple response
routes = await distribution_service.get_delivery_routes_for_date(tenant_id, date.today())
route = next((r for r in routes if r.get('id') == route_id), None)
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
except HTTPException:
raise
except Exception as e:
logger.error("Error getting route detail", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get route detail: {str(e)}")

View File

@@ -0,0 +1,166 @@
"""
Shipment API endpoints for distribution service
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from datetime import date, timedelta
from app.api.dependencies import get_distribution_service
from shared.auth.tenant_access import verify_tenant_access_dep
from shared.routing.route_builder import RouteBuilder
router = APIRouter()
# Initialize route builder for distribution service
route_builder = RouteBuilder('distribution')
@router.get(route_builder.build_base_route("shipments"))
async def get_shipments(
tenant_id: str,
date_from: Optional[date] = Query(None, description="Start date for shipment filtering"),
date_to: Optional[date] = Query(None, description="End date for shipment filtering"),
status: Optional[str] = Query(None, description="Filter by shipment status"),
distribution_service: object = Depends(get_distribution_service),
verified_tenant: str = Depends(verify_tenant_access_dep)
):
"""
List shipments with optional filtering
"""
try:
# If no date range specified, default to today
if not date_from and not date_to:
date_from = date.today()
date_to = date.today()
elif not date_to:
date_to = date_from
shipments = []
current_date = date_from
while current_date <= date_to:
daily_shipments = await distribution_service.get_shipments_for_date(tenant_id, current_date)
shipments.extend(daily_shipments)
current_date = current_date + timedelta(days=1)
if status:
shipments = [s for s in shipments if s.get('status') == status]
return {"shipments": shipments}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get shipments: {str(e)}")
@router.put(route_builder.build_base_route("shipments/{shipment_id}/status"))
async def update_shipment_status(
tenant_id: str,
shipment_id: str,
status_update: dict, # Should be a proper Pydantic model
distribution_service: object = Depends(get_distribution_service),
verified_tenant: str = Depends(verify_tenant_access_dep)
):
"""
Update shipment status
"""
try:
new_status = status_update.get('status')
if not new_status:
raise HTTPException(status_code=400, detail="Status is required")
user_id = "temp_user_id" # Would come from auth context
result = await distribution_service.update_shipment_status(
shipment_id=shipment_id,
new_status=new_status,
user_id=user_id,
metadata=status_update.get('metadata')
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update shipment status: {str(e)}")
@router.post(route_builder.build_base_route("shipments/{shipment_id}/delivery-proof"))
async def upload_delivery_proof(
tenant_id: str,
shipment_id: str,
delivery_proof: dict, # Should be a proper Pydantic model
distribution_service: object = Depends(get_distribution_service),
verified_tenant: str = Depends(verify_tenant_access_dep)
):
"""
Upload delivery proof (signature, photo, etc.)
Expected delivery_proof fields:
- signature: Base64 encoded signature image or signature data
- photo_url: URL to uploaded delivery photo
- received_by_name: Name of person who received delivery
- delivery_notes: Optional notes about delivery
"""
try:
user_id = "temp_user_id" # Would come from auth context
# Prepare metadata for shipment update
metadata = {}
if 'signature' in delivery_proof:
metadata['signature'] = delivery_proof['signature']
if 'photo_url' in delivery_proof:
metadata['photo_url'] = delivery_proof['photo_url']
if 'received_by_name' in delivery_proof:
metadata['received_by_name'] = delivery_proof['received_by_name']
if 'delivery_notes' in delivery_proof:
metadata['delivery_notes'] = delivery_proof['delivery_notes']
# Update shipment with delivery proof
result = await distribution_service.update_shipment_status(
shipment_id=shipment_id,
new_status='delivered', # Automatically mark as delivered when proof uploaded
user_id=user_id,
metadata=metadata
)
if not result:
raise HTTPException(status_code=404, detail="Shipment not found")
return {
"message": "Delivery proof uploaded successfully",
"shipment_id": shipment_id,
"status": "delivered"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to upload delivery proof: {str(e)}")
@router.get(route_builder.build_base_route("shipments/{shipment_id}"))
async def get_shipment_detail(
tenant_id: str,
shipment_id: str,
distribution_service: object = Depends(get_distribution_service),
verified_tenant: str = Depends(verify_tenant_access_dep)
):
"""
Get detailed information about a specific shipment including:
- Basic shipment info (number, date, status)
- Parent and child tenant details
- Delivery route assignment
- Purchase order reference
- Delivery proof (signature, photo, received by)
- Location tracking data
"""
try:
# Access the shipment repository from the distribution service
shipment = await distribution_service.shipment_repository.get_shipment_by_id(shipment_id)
if not shipment:
raise HTTPException(status_code=404, detail="Shipment not found")
# Verify tenant access
if str(shipment.get('tenant_id')) != tenant_id:
raise HTTPException(status_code=403, detail="Access denied to this shipment")
return shipment
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get shipment details: {str(e)}")

View File

@@ -0,0 +1,341 @@
"""
VRP Optimization API Endpoints
Endpoints for VRP optimization and metrics retrieval
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
import structlog
from app.services.vrp_optimization_service import VRPOptimizationService
from app.services.distribution_service import DistributionService
from shared.auth.tenant_access import verify_tenant_permission_dep
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter()
# Pydantic models for request/response
class VRPOptimizationRequest(BaseModel):
algorithm_version: str = Field(default="v2.1", description="VRP algorithm version to use")
constraints: Optional[Dict[str, Any]] = Field(
None,
description="Optimization constraints: max_route_duration, max_route_distance, etc."
)
class VRPOptimizationResponse(BaseModel):
success: bool
route_id: str
optimization_savings: Dict[str, Any]
vrp_algorithm_version: str
vrp_optimization_timestamp: str
vrp_constraints_satisfied: bool
vrp_objective_value: float
class RouteOptimizationMetrics(BaseModel):
route_id: str
route_number: str
route_date: str
vrp_optimization_savings: Optional[Dict[str, Any]]
vrp_algorithm_version: Optional[str]
vrp_optimization_timestamp: Optional[str]
vrp_constraints_satisfied: Optional[bool]
vrp_objective_value: Optional[float]
total_distance_km: Optional[float]
estimated_duration_minutes: Optional[int]
class NetworkOptimizationSummary(BaseModel):
total_routes: int
optimized_routes: int
total_distance_saved_km: float
total_time_saved_minutes: float
total_fuel_saved_liters: float
total_co2_saved_kg: float
total_cost_saved_eur: float
optimization_rate: float
average_savings_per_route: Optional[Dict[str, Any]]
class OptimizationHistoryItem(BaseModel):
optimization_id: str
route_id: str
timestamp: str
algorithm_version: str
distance_saved_km: float
time_saved_minutes: float
fuel_saved_liters: float
co2_saved_kg: float
cost_saved_eur: float
constraints_satisfied: bool
async def get_vrp_optimization_service() -> VRPOptimizationService:
"""Dependency injection for VRPOptimizationService"""
from app.core.database import database_manager
from app.services.distribution_service import DistributionService as BusinessDistributionService
from app.repositories.delivery_route_repository import DeliveryRouteRepository
from app.repositories.shipment_repository import ShipmentRepository
from app.repositories.delivery_schedule_repository import DeliveryScheduleRepository
from shared.clients.tenant_client import TenantServiceClient
from shared.clients.inventory_client import InventoryServiceClient
from shared.clients.procurement_client import ProcurementServiceClient
from app.services.routing_optimizer import RoutingOptimizer
# Create the business distribution service with proper dependencies
route_repository = DeliveryRouteRepository(database_manager.get_session())
shipment_repository = ShipmentRepository(database_manager.get_session())
schedule_repository = DeliveryScheduleRepository(database_manager.get_session())
# Create client instances (these will be initialized with proper config)
tenant_client = TenantServiceClient()
inventory_client = InventoryServiceClient()
procurement_client = ProcurementServiceClient()
routing_optimizer = RoutingOptimizer()
distribution_service = BusinessDistributionService(
route_repository=route_repository,
shipment_repository=shipment_repository,
schedule_repository=schedule_repository,
procurement_client=procurement_client,
tenant_client=tenant_client,
inventory_client=inventory_client,
routing_optimizer=routing_optimizer
)
return VRPOptimizationService(distribution_service, database_manager)
@router.post("/tenants/{tenant_id}/routes/{route_id}/optimize",
response_model=VRPOptimizationResponse,
summary="Optimize delivery route with VRP")
async def optimize_route_with_vrp(
tenant_id: str,
route_id: str,
optimization_request: VRPOptimizationRequest,
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Optimize a delivery route using VRP algorithm
This endpoint applies VRP optimization to a specific delivery route and stores
the optimization metrics for analysis and reporting.
"""
try:
result = await vrp_service.optimize_route_with_vrp(
route_id=route_id,
algorithm_version=optimization_request.algorithm_version,
constraints=optimization_request.constraints
)
if not result.get('success'):
raise HTTPException(status_code=500, detail="Optimization failed")
return VRPOptimizationResponse(
success=True,
route_id=result['route_id'],
optimization_savings=result['optimization_savings'],
vrp_algorithm_version=result['optimization_savings'].get('algorithm_version', optimization_request.algorithm_version),
vrp_optimization_timestamp=result['optimization_savings'].get('timestamp', datetime.now().isoformat()),
vrp_constraints_satisfied=result['optimization_savings'].get('constraints_satisfied', True),
vrp_objective_value=result['optimization_savings'].get('objective_value', 0.0)
)
except Exception as e:
logger.error("VRP optimization failed", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"VRP optimization failed: {str(e)}")
@router.get("/tenants/{tenant_id}/routes/{route_id}/optimization-metrics",
response_model=RouteOptimizationMetrics,
summary="Get VRP optimization metrics for route")
async def get_route_optimization_metrics(
tenant_id: str,
route_id: str,
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get VRP optimization metrics for a specific route
Retrieves stored optimization metrics including savings, algorithm version,
and constraint satisfaction status.
"""
try:
metrics = await vrp_service.get_route_optimization_metrics(route_id)
return RouteOptimizationMetrics(**metrics)
except Exception as e:
logger.error("Failed to get route optimization metrics", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get optimization metrics: {str(e)}")
@router.get("/tenants/{tenant_id}/vrp/optimization-summary",
response_model=NetworkOptimizationSummary,
summary="Get network-wide VRP optimization summary")
async def get_network_optimization_summary(
tenant_id: str,
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get aggregated VRP optimization metrics across all routes
Provides network-wide summary of optimization benefits including
total savings, optimization rate, and average improvements.
"""
try:
summary = await vrp_service.get_network_optimization_summary(tenant_id)
return NetworkOptimizationSummary(**summary)
except Exception as e:
logger.error("Failed to get network optimization summary", tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get optimization summary: {str(e)}")
@router.post("/tenants/{tenant_id}/vrp/batch-optimize",
summary="Batch optimize multiple routes")
async def batch_optimize_routes(
tenant_id: str,
route_ids: List[str] = Query(..., description="List of route IDs to optimize"),
algorithm_version: str = Query("v2.1", description="VRP algorithm version"),
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Batch optimize multiple delivery routes with VRP
Applies VRP optimization to multiple routes in a single request.
"""
try:
result = await vrp_service.batch_optimize_routes(tenant_id, route_ids)
return {
'success': True,
'total_routes_processed': result['total_routes_processed'],
'successful_optimizations': result['successful_optimizations'],
'failed_optimizations': result['failed_optimizations'],
'results': result['results']
}
except Exception as e:
logger.error("Batch optimization failed", tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Batch optimization failed: {str(e)}")
@router.get("/tenants/{tenant_id}/routes/{route_id}/optimization-history",
response_model=List[OptimizationHistoryItem],
summary="Get optimization history for route")
async def get_optimization_history(
tenant_id: str,
route_id: str,
limit: int = Query(10, description="Maximum number of historical records to return"),
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get historical optimization records for a route
Retrieves past optimization runs and their results for analysis.
"""
try:
history = await vrp_service.get_optimization_history(route_id, limit)
return [OptimizationHistoryItem(**item) for item in history]
except Exception as e:
logger.error("Failed to get optimization history", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get optimization history: {str(e)}")
@router.get("/tenants/{tenant_id}/vrp/constraints/validate",
summary="Validate VRP constraints")
async def validate_vrp_constraints(
tenant_id: str,
route_id: str,
max_route_duration: Optional[int] = Query(None, description="Maximum route duration in minutes"),
max_route_distance: Optional[float] = Query(None, description="Maximum route distance in km"),
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Validate VRP constraints against a route
Checks if a route satisfies specified VRP constraints.
"""
try:
from app.services.vrp_optimization_service import VRPConstraintValidator
# Get route data
route = await vrp_service.distribution_service.get_delivery_route(route_id)
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Build constraints dict
constraints = {}
if max_route_duration is not None:
constraints['max_route_duration'] = max_route_duration
if max_route_distance is not None:
constraints['max_route_distance'] = max_route_distance
# Validate constraints
validation_result = VRPConstraintValidator.validate_constraints(route, constraints)
return {
'success': True,
'all_constraints_satisfied': validation_result['all_satisfied'],
'constraint_violations': validation_result['constraint_violations']
}
except Exception as e:
logger.error("Failed to validate VRP constraints", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to validate constraints: {str(e)}")
@router.post("/tenants/{tenant_id}/vrp/simulate",
summary="Simulate VRP optimization")
async def simulate_vrp_optimization(
tenant_id: str,
route_id: str,
vrp_service: VRPOptimizationService = Depends(get_vrp_optimization_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Simulate VRP optimization without saving results
Useful for testing and previewing optimization results.
"""
try:
from app.services.vrp_optimization_service import VRPOptimizationSimulator
# Get route data
route = await vrp_service.distribution_service.get_delivery_route(route_id)
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Simulate optimization
simulation_result = VRPOptimizationSimulator.simulate_optimization(route)
return {
'success': True,
'original_route': simulation_result['original_route'],
'optimized_route': simulation_result['optimized_route'],
'optimization_savings': simulation_result['optimization_savings'],
'algorithm_version': simulation_result['algorithm_version'],
'constraints_satisfied': simulation_result['constraints_satisfied'],
'objective_value': simulation_result['objective_value']
}
except Exception as e:
logger.error("VRP simulation failed", tenant_id=tenant_id, route_id=route_id, error=str(e))
raise HTTPException(status_code=500, detail=f"VRP simulation failed: {str(e)}")
# Import datetime at runtime to avoid circular imports
from datetime import datetime