Initial commit - production deployment
This commit is contained in:
477
shared/clients/distribution_client.py
Executable file
477
shared/clients/distribution_client.py
Executable file
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user