""" Distribution Service Client for Inter-Service Communication This client provides a high-level API for interacting with the Distribution Service, which manages delivery routes, shipment tracking, and vehicle routing optimization for enterprise multi-location bakery networks. Key Capabilities: - Generate daily distribution plans using VRP (Vehicle Routing Problem) optimization - Manage delivery routes with driver assignments and route sequencing - Track shipments from pending → packed → in_transit → delivered - Update shipment status with proof of delivery (POD) metadata - Filter routes and shipments by date range and status - Setup enterprise distribution for demo sessions Enterprise Context: - Designed for parent-child tenant hierarchies (central production + retail outlets) - Routes optimize deliveries from parent (central bakery) to children (outlets) - Integrates with Procurement Service (internal transfer POs) and Inventory Service (stock transfers) - Publishes shipment.delivered events for inventory ownership transfer Usage Example: ```python from shared.clients import create_distribution_client from shared.config.base import get_settings config = get_settings() client = create_distribution_client(config, service_name="orchestrator") # Generate daily distribution plan plan = await client.generate_daily_distribution_plan( tenant_id=parent_tenant_id, target_date=date.today(), vehicle_capacity_kg=1000.0 ) # Get active delivery routes routes = await client.get_delivery_routes( tenant_id=parent_tenant_id, status="in_progress" ) # Update shipment to delivered await client.update_shipment_status( tenant_id=parent_tenant_id, shipment_id=shipment_id, new_status="delivered", user_id=driver_id, metadata={"signature": "...", "photo_url": "..."} ) ``` Service Architecture: - Base URL: Configured via DISTRIBUTION_SERVICE_URL environment variable - Authentication: Uses BaseServiceClient with tenant_id header validation - Error Handling: Returns None on errors, logs detailed error context - Async: All methods are async and use httpx for HTTP communication Related Services: - Procurement Service: Approved internal transfer POs feed into distribution planning - Inventory Service: Consumes shipment.delivered events for stock ownership transfer - Tenant Service: Validates parent-child tenant relationships and location data - Orchestrator Service: Enterprise dashboard displays delivery route status For more details, see services/distribution/README.md """ import structlog from typing import Dict, Any, List, Optional from datetime import date from shared.clients.base_service_client import BaseServiceClient from shared.config.base import BaseServiceSettings logger = structlog.get_logger() class DistributionServiceClient(BaseServiceClient): """Client for communicating with the Distribution Service""" def __init__(self, config: BaseServiceSettings, service_name: str = "unknown"): super().__init__(service_name, config) self.service_base_url = config.DISTRIBUTION_SERVICE_URL def get_service_base_path(self) -> str: return "/api/v1" # ================================================================ # DAILY DISTRIBUTION PLAN ENDPOINTS # ================================================================ async def generate_daily_distribution_plan( self, tenant_id: str, target_date: date, vehicle_capacity_kg: float = 1000.0 ) -> Optional[Dict[str, Any]]: """ Generate daily distribution plan for internal transfers Args: tenant_id: Tenant ID (should be parent tenant for enterprise) target_date: Date for which to generate distribution plan vehicle_capacity_kg: Maximum capacity per vehicle Returns: Distribution plan details """ try: response = await self.post( f"tenants/{tenant_id}/distribution/plans/generate", data={ "target_date": target_date.isoformat(), "vehicle_capacity_kg": vehicle_capacity_kg }, tenant_id=tenant_id ) if response: logger.info("Generated daily distribution plan", tenant_id=tenant_id, target_date=target_date.isoformat()) return response except Exception as e: logger.error("Error generating distribution plan", tenant_id=tenant_id, target_date=target_date, error=str(e)) return None # ================================================================ # DELIVERY ROUTES ENDPOINTS # ================================================================ async def get_delivery_routes( self, tenant_id: str, date_from: Optional[date] = None, date_to: Optional[date] = None, status: Optional[str] = None ) -> Optional[List[Dict[str, Any]]]: """ Get delivery routes with optional filtering Args: tenant_id: Tenant ID date_from: Start date for filtering date_to: End date for filtering status: Status filter Returns: List of delivery route dictionaries """ try: params = {} if date_from: params["date_from"] = date_from.isoformat() if date_to: params["date_to"] = date_to.isoformat() if status: params["status"] = status response = await self.get( f"tenants/{tenant_id}/distribution/routes", params=params, tenant_id=tenant_id ) if response: logger.info("Retrieved delivery routes", tenant_id=tenant_id, count=len(response.get("routes", []))) return response.get("routes", []) if response else [] except Exception as e: logger.error("Error getting delivery routes", tenant_id=tenant_id, error=str(e)) return [] async def get_delivery_route_detail( self, tenant_id: str, route_id: str ) -> Optional[Dict[str, Any]]: """ Get detailed information about a specific delivery route Args: tenant_id: Tenant ID route_id: Route ID Returns: Delivery route details """ try: response = await self.get( f"tenants/{tenant_id}/distribution/routes/{route_id}", tenant_id=tenant_id ) if response: logger.info("Retrieved delivery route detail", tenant_id=tenant_id, route_id=route_id) return response except Exception as e: logger.error("Error getting delivery route detail", tenant_id=tenant_id, route_id=route_id, error=str(e)) return None # ================================================================ # SHIPMENT ENDPOINTS # ================================================================ async def get_shipments( self, tenant_id: str, date_from: Optional[date] = None, date_to: Optional[date] = None, status: Optional[str] = None ) -> Optional[List[Dict[str, Any]]]: """ Get shipments with optional filtering Args: tenant_id: Tenant ID date_from: Start date for filtering date_to: End date for filtering status: Status filter Returns: List of shipment dictionaries """ try: params = {} if date_from: params["date_from"] = date_from.isoformat() if date_to: params["date_to"] = date_to.isoformat() if status: params["status"] = status response = await self.get( f"tenants/{tenant_id}/distribution/shipments", params=params, tenant_id=tenant_id ) if response: logger.info("Retrieved shipments", tenant_id=tenant_id, count=len(response.get("shipments", []))) return response.get("shipments", []) if response else [] except Exception as e: logger.error("Error getting shipments", tenant_id=tenant_id, error=str(e)) return [] async def get_shipment_detail( self, tenant_id: str, shipment_id: str ) -> Optional[Dict[str, Any]]: """ Get detailed information about a specific shipment Args: tenant_id: Tenant ID shipment_id: Shipment ID Returns: Shipment details """ try: response = await self.get( f"tenants/{tenant_id}/distribution/shipments/{shipment_id}", tenant_id=tenant_id ) if response: logger.info("Retrieved shipment detail", tenant_id=tenant_id, shipment_id=shipment_id) return response except Exception as e: logger.error("Error getting shipment detail", tenant_id=tenant_id, shipment_id=shipment_id, error=str(e)) return None async def update_shipment_status( self, tenant_id: str, shipment_id: str, new_status: str, user_id: str, metadata: Optional[Dict[str, Any]] = None ) -> Optional[Dict[str, Any]]: """ Update shipment status Args: tenant_id: Tenant ID shipment_id: Shipment ID new_status: New status user_id: User ID performing update metadata: Additional metadata for the update Returns: Updated shipment details """ try: payload = { "status": new_status, "updated_by_user_id": user_id, "metadata": metadata or {} } response = await self.put( f"tenants/{tenant_id}/distribution/shipments/{shipment_id}/status", data=payload, tenant_id=tenant_id ) if response: logger.info("Updated shipment status", tenant_id=tenant_id, shipment_id=shipment_id, new_status=new_status) return response except Exception as e: logger.error("Error updating shipment status", tenant_id=tenant_id, shipment_id=shipment_id, new_status=new_status, error=str(e)) return None # ================================================================ # INTERNAL DEMO ENDPOINTS # ================================================================ async def setup_enterprise_distribution_demo( self, parent_tenant_id: str, child_tenant_ids: List[str], session_id: str ) -> Optional[Dict[str, Any]]: """ Internal endpoint to setup distribution for enterprise demo Args: parent_tenant_id: Parent tenant ID child_tenant_ids: List of child tenant IDs session_id: Demo session ID Returns: Distribution setup result """ try: url = f"{self.service_base_url}/api/v1/internal/demo/setup" async with self.get_http_client() as client: response = await client.post( url, json={ "parent_tenant_id": parent_tenant_id, "child_tenant_ids": child_tenant_ids, "session_id": session_id }, headers={ "X-Internal-API-Key": self.config.INTERNAL_API_KEY, "Content-Type": "application/json" } ) if response.status_code == 200: result = response.json() logger.info("Setup enterprise distribution demo", parent_tenant_id=parent_tenant_id, child_count=len(child_tenant_ids)) return result else: logger.error("Failed to setup enterprise distribution demo", status_code=response.status_code, response_text=response.text) return None except Exception as e: logger.error("Error setting up enterprise distribution demo", parent_tenant_id=parent_tenant_id, error=str(e)) return None async def get_shipments_for_date( self, tenant_id: str, target_date: date ) -> Optional[List[Dict[str, Any]]]: """ Get all shipments for a specific date Args: tenant_id: Tenant ID target_date: Target date Returns: List of shipments for the date """ try: response = await self.get( f"tenants/{tenant_id}/distribution/shipments", params={ "date_from": target_date.isoformat(), "date_to": target_date.isoformat() }, tenant_id=tenant_id ) if response: logger.info("Retrieved shipments for date", tenant_id=tenant_id, target_date=target_date.isoformat(), shipment_count=len(response.get("shipments", []))) return response.get("shipments", []) if response else [] except Exception as e: logger.error("Error getting shipments for date", tenant_id=tenant_id, target_date=target_date, error=str(e)) return [] # ================================================================ # HEALTH CHECK # ================================================================ async def health_check(self) -> bool: """Check if distribution service is healthy""" try: # Use base health check method response = await self.get("health") return response is not None except Exception as e: logger.error("Distribution service health check failed", error=str(e)) return False # Factory function for dependency injection def create_distribution_client(config: BaseServiceSettings, service_name: str = "unknown") -> DistributionServiceClient: """Create distribution service client instance""" return DistributionServiceClient(config, service_name)