478 lines
17 KiB
Python
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)
|