Files
bakery-ia/shared/clients/distribution_client.py
2025-12-05 20:07:01 +01:00

478 lines
17 KiB
Python

"""
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
# Use _make_request directly to construct correct URL
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
response = await self._make_request(
"GET",
f"tenants/{tenant_id}/distribution/routes",
params=params
)
if response:
# Handle different response formats
if isinstance(response, list):
# Direct list of routes
logger.info("Retrieved delivery routes",
tenant_id=tenant_id,
count=len(response))
return response
elif isinstance(response, dict):
# Response wrapped in routes key
if "routes" in response:
logger.info("Retrieved delivery routes",
tenant_id=tenant_id,
count=len(response.get("routes", [])))
return response.get("routes", [])
else:
# Return the whole dict if it's a single route
logger.info("Retrieved delivery routes",
tenant_id=tenant_id,
count=1)
return [response]
logger.info("No delivery routes found",
tenant_id=tenant_id)
return []
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"distribution/routes/{route_id}",
tenant_id=tenant_id
)
if response:
logger.info("Retrieved delivery route detail",
tenant_id=tenant_id,
route_id=route_id)
# Ensure we return the route data directly if it's wrapped in a route key
if isinstance(response, dict) and "route" in response:
return response["route"]
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
# Use _make_request directly to construct correct URL
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
response = await self._make_request(
"GET",
f"tenants/{tenant_id}/distribution/shipments",
params=params
)
if response:
# Handle different response formats
if isinstance(response, list):
# Direct list of shipments
logger.info("Retrieved shipments",
tenant_id=tenant_id,
count=len(response))
return response
elif isinstance(response, dict):
# Response wrapped in shipments key
if "shipments" in response:
logger.info("Retrieved shipments",
tenant_id=tenant_id,
count=len(response.get("shipments", [])))
return response.get("shipments", [])
else:
# Return the whole dict if it's a single shipment
logger.info("Retrieved shipments",
tenant_id=tenant_id,
count=1)
return [response]
logger.info("No shipments found",
tenant_id=tenant_id)
return []
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"distribution/shipments/{shipment_id}",
tenant_id=tenant_id
)
if response:
logger.info("Retrieved shipment detail",
tenant_id=tenant_id,
shipment_id=shipment_id)
# Ensure we return the shipment data directly if it's wrapped in a shipment key
if isinstance(response, dict) and "shipment" in response:
return response["shipment"]
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"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
# ================================================================
# Legacy setup_enterprise_distribution_demo method removed
# Distribution now uses standard /internal/demo/clone endpoint via DataCloner
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:
# Use _make_request directly to construct correct URL
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
response = await self._make_request(
"GET",
f"tenants/{tenant_id}/distribution/shipments",
params={
"date_from": target_date.isoformat(),
"date_to": target_date.isoformat()
}
)
if response:
# Handle different response formats
if isinstance(response, list):
# Direct list of shipments
logger.info("Retrieved shipments for date",
tenant_id=tenant_id,
target_date=target_date.isoformat(),
shipment_count=len(response))
return response
elif isinstance(response, dict):
# Response wrapped in shipments key
if "shipments" in 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", [])
else:
# Return the whole dict if it's a single shipment
logger.info("Retrieved shipments for date",
tenant_id=tenant_id,
target_date=target_date.isoformat(),
shipment_count=1)
return [response]
logger.info("No shipments found for date",
tenant_id=tenant_id,
target_date=target_date.isoformat())
return []
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)