New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

View File

@@ -0,0 +1,454 @@
"""
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)