Files
bakery-ia/services/distribution/app/api/internal_demo.py
2025-11-30 09:12:40 +01:00

452 lines
21 KiB
Python

"""
Internal Demo API for Distribution Service
Handles internal demo setup for enterprise tier
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from typing import Dict, Any, List
import structlog
from datetime import datetime
import uuid
from app.services.distribution_service import DistributionService
from app.api.dependencies import get_distribution_service
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter()
async def verify_internal_api_key(x_internal_api_key: str = Header(None)):
"""Verify internal API key for service-to-service communication"""
required_key = settings.INTERNAL_API_KEY
if x_internal_api_key != required_key:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True
@router.post("/internal/demo/setup")
async def setup_demo_distribution(
setup_request: dict, # Contains parent_tenant_id, child_tenant_ids, session_id
distribution_service: DistributionService = Depends(get_distribution_service),
_: bool = Depends(verify_internal_api_key)
):
"""
Internal endpoint to setup distribution for enterprise demo
Args:
setup_request: Contains parent_tenant_id, child_tenant_ids, session_id
"""
try:
parent_tenant_id = setup_request.get('parent_tenant_id')
child_tenant_ids = setup_request.get('child_tenant_ids', [])
session_id = setup_request.get('session_id')
if not all([parent_tenant_id, child_tenant_ids, session_id]):
raise HTTPException(
status_code=400,
detail="Missing required parameters: parent_tenant_id, child_tenant_ids, session_id"
)
logger.info("Setting up demo distribution",
parent=parent_tenant_id,
children=child_tenant_ids,
session_id=session_id)
# Get locations for parent and children to set up delivery routes
parent_locations_response = await distribution_service.tenant_client.get_tenant_locations(parent_tenant_id)
# Check if parent_locations_response is None (which happens when the API call fails)
if not parent_locations_response:
logger.warning(f"No locations found for parent tenant {parent_tenant_id}")
raise HTTPException(
status_code=404,
detail=f"No locations found for parent tenant {parent_tenant_id}. "
f"Ensure the tenant exists and has locations configured."
)
# Extract the actual locations array from the response object
# The response format is {"locations": [...], "total": N}
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')}")
# BUG-013 FIX: Use HTTPException instead of ValueError
if not parent_location:
raise HTTPException(
status_code=404,
detail=f"No location found for parent tenant {parent_tenant_id} to use as distribution center. "
f"Ensure the parent tenant has at least one location configured."
)
# Create delivery schedules for each child
for child_id in child_tenant_ids:
try:
child_locations_response = await distribution_service.tenant_client.get_tenant_locations(child_id)
# Check if child_locations_response is None (which happens when the API call fails)
if not child_locations_response:
logger.warning(f"No locations found for child tenant {child_id}")
continue # Skip this child tenant and continue with the next one
# Extract the actual locations array from the response object
# The response format is {"locations": [...], "total": N}
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 = {
'tenant_id': child_id, # The child tenant that will receive deliveries
'target_parent_tenant_id': parent_tenant_id, # The parent tenant that supplies
'target_child_tenant_ids': [child_id], # Array of child tenant IDs in this 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,
'created_by': parent_tenant_id, # BUG FIX: Add required created_by field
'updated_by': parent_tenant_id # BUG FIX: Add required updated_by field
}
# Create the delivery schedule record
schedule = await distribution_service.create_delivery_schedule(schedule_data)
logger.info(f"Created delivery schedule for {parent_tenant_id} to {child_id}")
except Exception as e:
logger.error(f"Error creating delivery schedule for child {child_id}: {e}", exc_info=True)
continue # Continue with the next child
# BUG-012 FIX: Use demo reference date instead of actual today
from datetime import date
from shared.utils.demo_dates import BASE_REFERENCE_DATE
# Get demo reference date from session metadata if available
session_metadata = setup_request.get('session_metadata', {})
session_created_at = session_metadata.get('session_created_at')
if session_created_at:
# Use the BASE_REFERENCE_DATE for consistent demo data dating
# All demo data is anchored to this date (November 25, 2025)
demo_today = BASE_REFERENCE_DATE
logger.info(f"Using demo reference date: {demo_today}")
else:
# Fallback to today if no session metadata (shouldn't happen in production)
demo_today = date.today()
logger.warning(f"No session_created_at in metadata, using today: {demo_today}")
delivery_data = []
# Prepare delivery information for each child
for child_id in child_tenant_ids:
try:
child_locations_response = await distribution_service.tenant_client.get_tenant_locations(child_id)
# Check if child_locations_response is None (which happens when the API call fails)
if not child_locations_response:
logger.warning(f"No locations found for child delivery {child_id}")
continue # Skip this child tenant and continue with the next one
# Extract the actual locations array from the response object
# The response format is {"locations": [...], "total": N}
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')
# BUG-013 FIX: Use HTTPException for coordinate validation errors
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 HTTPException(
status_code=400,
detail=f"Parent location {parent_tenant_id} missing coordinates. "
f"Latitude and longitude must be provided for distribution planning."
)
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 HTTPException(
status_code=400,
detail=f"Parent location {parent_tenant_id} has invalid coordinates: {e}"
)
optimization_result = await distribution_service.routing_optimizer.optimize_daily_routes(
deliveries=delivery_data,
depot_location=depot_location,
vehicle_capacity_kg=1000.0 # Standard vehicle capacity
)
# BUG-012 FIX: Create the delivery route using demo reference date
routes = optimization_result.get('routes', [])
route_sequence = routes[0].get('route_sequence', []) if routes else []
# Use session_id suffix to ensure unique route numbers for concurrent demo sessions
session_suffix = session_id.split('_')[-1][:8] if session_id else '001'
route = await distribution_service.route_repository.create_route({
'tenant_id': uuid.UUID(parent_tenant_id),
'route_number': f"DEMO-{demo_today.strftime('%Y%m%d')}-{session_suffix}",
'route_date': datetime.combine(demo_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': route_sequence,
'status': 'planned'
})
# BUG-012 FIX: Create shipment records using demo reference date
# Use session_id suffix to ensure unique shipment numbers
shipments = []
for idx, delivery in enumerate(delivery_data):
shipment = await distribution_service.shipment_repository.create_shipment({
'tenant_id': uuid.UUID(parent_tenant_id),
'parent_tenant_id': uuid.UUID(parent_tenant_id),
'child_tenant_id': uuid.UUID(delivery['child_tenant_id']),
'shipment_number': f"DEMOSHP-{demo_today.strftime('%Y%m%d')}-{session_suffix}-{idx+1:03d}",
'shipment_date': datetime.combine(demo_today, datetime.min.time()),
'status': 'pending',
'total_weight_kg': delivery['weight_kg']
})
shipments.append(shipment)
logger.info(f"Demo distribution setup completed: 1 route, {len(shipments)} shipments")
return {
"status": "completed",
"route_id": str(route['id']),
"shipment_count": len(shipments),
"total_distance_km": optimization_result.get('total_distance_km', 0),
"session_id": session_id
}
except Exception as e:
logger.error(f"Error setting up demo distribution: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to setup demo distribution: {str(e)}")
@router.post("/internal/demo/cleanup")
async def cleanup_demo_distribution(
cleanup_request: dict, # Contains parent_tenant_id, child_tenant_ids, session_id
distribution_service: DistributionService = Depends(get_distribution_service),
_: bool = Depends(verify_internal_api_key)
):
"""
Internal endpoint to cleanup distribution data for enterprise demo
Args:
cleanup_request: Contains parent_tenant_id, child_tenant_ids, session_id
"""
try:
parent_tenant_id = cleanup_request.get('parent_tenant_id')
child_tenant_ids = cleanup_request.get('child_tenant_ids', [])
session_id = cleanup_request.get('session_id')
if not all([parent_tenant_id, session_id]):
raise HTTPException(
status_code=400,
detail="Missing required parameters: parent_tenant_id, session_id"
)
logger.info("Cleaning up demo distribution",
parent=parent_tenant_id,
session_id=session_id)
# Delete all demo routes and shipments for this parent tenant
deleted_routes_count = await distribution_service.route_repository.delete_demo_routes_for_tenant(
tenant_id=parent_tenant_id
)
deleted_shipments_count = await distribution_service.shipment_repository.delete_demo_shipments_for_tenant(
tenant_id=parent_tenant_id
)
logger.info(f"Demo distribution cleanup completed: {deleted_routes_count} routes, {deleted_shipments_count} shipments deleted")
return {
"status": "completed",
"routes_deleted": deleted_routes_count,
"shipments_deleted": deleted_shipments_count,
"session_id": session_id
}
except Exception as e:
logger.error(f"Error cleaning up demo distribution: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to cleanup demo distribution: {str(e)}")
@router.get("/internal/health")
async def internal_health_check(
_: bool = Depends(verify_internal_api_key)
):
"""
Internal health check endpoint
"""
return {
"service": "distribution-service",
"endpoint": "internal-demo",
"status": "healthy",
"timestamp": datetime.utcnow().isoformat()
}
@router.post("/internal/demo/clone")
async def clone_demo_data(
clone_request: dict,
distribution_service: DistributionService = Depends(get_distribution_service),
_: bool = Depends(verify_internal_api_key)
):
"""
Clone/Setup distribution data for a virtual demo tenant
Args:
clone_request: Contains base_tenant_id, virtual_tenant_id, session_id, demo_account_type
"""
try:
virtual_tenant_id = clone_request.get('virtual_tenant_id')
session_id = clone_request.get('session_id')
if not all([virtual_tenant_id, session_id]):
raise HTTPException(
status_code=400,
detail="Missing required parameters: virtual_tenant_id, session_id"
)
logger.info("Cloning distribution data",
virtual_tenant_id=virtual_tenant_id,
session_id=session_id)
# 1. Fetch child tenants for the new virtual parent
child_tenants = await distribution_service.tenant_client.get_child_tenants(virtual_tenant_id)
if not child_tenants:
logger.warning(f"No child tenants found for virtual parent {virtual_tenant_id}, skipping distribution setup")
return {
"status": "skipped",
"reason": "no_child_tenants",
"virtual_tenant_id": virtual_tenant_id
}
child_tenant_ids = [child['id'] for child in child_tenants]
# 2. Call existing setup logic
result = await distribution_service.setup_demo_enterprise_distribution(
parent_tenant_id=virtual_tenant_id,
child_tenant_ids=child_tenant_ids,
session_id=session_id
)
return {
"service": "distribution",
"status": "completed",
"records_cloned": result.get('shipment_count', 0) + 1, # shipments + 1 route
"details": result
}
except Exception as e:
logger.error(f"Error cloning distribution data: {e}", exc_info=True)
# Don't fail the entire cloning process if distribution fails
return {
"service": "distribution",
"status": "failed",
"error": str(e)
}
@router.delete("/internal/demo/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
distribution_service: DistributionService = Depends(get_distribution_service),
_: bool = Depends(verify_internal_api_key)
):
"""Delete all distribution data for a virtual demo tenant"""
try:
logger.info("Deleting distribution data", virtual_tenant_id=virtual_tenant_id)
# Reuse existing cleanup logic
deleted_routes = await distribution_service.route_repository.delete_demo_routes_for_tenant(
tenant_id=virtual_tenant_id
)
deleted_shipments = await distribution_service.shipment_repository.delete_demo_shipments_for_tenant(
tenant_id=virtual_tenant_id
)
return {
"service": "distribution",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"routes": deleted_routes,
"shipments": deleted_shipments
}
}
except Exception as e:
logger.error(f"Error deleting distribution data: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))