""" 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))