New enterprise feature
This commit is contained in:
@@ -18,6 +18,7 @@ from .suppliers_client import SuppliersServiceClient
|
||||
from .tenant_client import TenantServiceClient
|
||||
from .ai_insights_client import AIInsightsClient
|
||||
from .alerts_client import AlertsServiceClient
|
||||
from .procurement_client import ProcurementServiceClient
|
||||
|
||||
# Import config
|
||||
from shared.config.base import BaseServiceSettings
|
||||
@@ -69,10 +70,10 @@ def get_inventory_client(config: BaseServiceSettings = None, service_name: str =
|
||||
"""Get or create an inventory service client"""
|
||||
if config is None:
|
||||
from app.core.config import settings as config
|
||||
|
||||
|
||||
cache_key = f"inventory_{service_name}"
|
||||
if cache_key not in _client_cache:
|
||||
_client_cache[cache_key] = InventoryServiceClient(config)
|
||||
_client_cache[cache_key] = InventoryServiceClient(config, service_name)
|
||||
return _client_cache[cache_key]
|
||||
|
||||
def get_orders_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> OrdersServiceClient:
|
||||
@@ -89,20 +90,20 @@ def get_production_client(config: BaseServiceSettings = None, service_name: str
|
||||
"""Get or create a production service client"""
|
||||
if config is None:
|
||||
from app.core.config import settings as config
|
||||
|
||||
|
||||
cache_key = f"production_{service_name}"
|
||||
if cache_key not in _client_cache:
|
||||
_client_cache[cache_key] = ProductionServiceClient(config)
|
||||
_client_cache[cache_key] = ProductionServiceClient(config, service_name)
|
||||
return _client_cache[cache_key]
|
||||
|
||||
def get_recipes_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> RecipesServiceClient:
|
||||
"""Get or create a recipes service client"""
|
||||
if config is None:
|
||||
from app.core.config import settings as config
|
||||
|
||||
|
||||
cache_key = f"recipes_{service_name}"
|
||||
if cache_key not in _client_cache:
|
||||
_client_cache[cache_key] = RecipesServiceClient(config)
|
||||
_client_cache[cache_key] = RecipesServiceClient(config, service_name)
|
||||
return _client_cache[cache_key]
|
||||
|
||||
def get_suppliers_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> SuppliersServiceClient:
|
||||
@@ -112,7 +113,7 @@ def get_suppliers_client(config: BaseServiceSettings = None, service_name: str =
|
||||
|
||||
cache_key = f"suppliers_{service_name}"
|
||||
if cache_key not in _client_cache:
|
||||
_client_cache[cache_key] = SuppliersServiceClient(config)
|
||||
_client_cache[cache_key] = SuppliersServiceClient(config, service_name)
|
||||
return _client_cache[cache_key]
|
||||
|
||||
def get_alerts_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> AlertsServiceClient:
|
||||
@@ -125,6 +126,26 @@ def get_alerts_client(config: BaseServiceSettings = None, service_name: str = "u
|
||||
_client_cache[cache_key] = AlertsServiceClient(config, service_name)
|
||||
return _client_cache[cache_key]
|
||||
|
||||
def get_tenant_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> TenantServiceClient:
|
||||
"""Get or create a tenant service client"""
|
||||
if config is None:
|
||||
from app.core.config import settings as config
|
||||
|
||||
cache_key = f"tenant_{service_name}"
|
||||
if cache_key not in _client_cache:
|
||||
_client_cache[cache_key] = TenantServiceClient(config)
|
||||
return _client_cache[cache_key]
|
||||
|
||||
def get_procurement_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> ProcurementServiceClient:
|
||||
"""Get or create a procurement service client"""
|
||||
if config is None:
|
||||
from app.core.config import settings as config
|
||||
|
||||
cache_key = f"procurement_{service_name}"
|
||||
if cache_key not in _client_cache:
|
||||
_client_cache[cache_key] = ProcurementServiceClient(config, service_name)
|
||||
return _client_cache[cache_key]
|
||||
|
||||
|
||||
class ServiceClients:
|
||||
"""Convenient wrapper for all service clients"""
|
||||
@@ -247,5 +268,10 @@ __all__ = [
|
||||
'get_recipes_client',
|
||||
'get_suppliers_client',
|
||||
'get_alerts_client',
|
||||
'get_service_clients'
|
||||
]
|
||||
'get_tenant_client',
|
||||
'get_procurement_client',
|
||||
'get_service_clients',
|
||||
'create_forecast_client'
|
||||
]
|
||||
# Backward compatibility aliases
|
||||
create_forecast_client = get_forecast_client
|
||||
|
||||
@@ -205,7 +205,18 @@ class BaseServiceClient(ABC):
|
||||
full_endpoint = f"{base_path}/{endpoint.lstrip('/')}"
|
||||
|
||||
url = urljoin(self.gateway_url, full_endpoint)
|
||||
|
||||
|
||||
# Debug logging for URL construction
|
||||
logger.debug(
|
||||
"Making service request",
|
||||
service=self.service_name,
|
||||
method=method,
|
||||
url=url,
|
||||
tenant_id=tenant_id,
|
||||
endpoint=endpoint,
|
||||
params=params
|
||||
)
|
||||
|
||||
# Make request with retries
|
||||
for attempt in range(self.retries + 1):
|
||||
try:
|
||||
@@ -240,7 +251,14 @@ class BaseServiceClient(ABC):
|
||||
logger.error("Authentication failed after retry")
|
||||
return None
|
||||
elif response.status_code == 404:
|
||||
logger.warning(f"Endpoint not found: {url}")
|
||||
logger.warning(
|
||||
"Endpoint not found",
|
||||
url=url,
|
||||
service=self.service_name,
|
||||
endpoint=endpoint,
|
||||
constructed_endpoint=full_endpoint,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return None
|
||||
else:
|
||||
error_detail = "Unknown error"
|
||||
|
||||
454
shared/clients/distribution_client.py
Normal file
454
shared/clients/distribution_client.py
Normal 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)
|
||||
@@ -1,12 +1,92 @@
|
||||
# shared/clients/forecast_client.py
|
||||
"""
|
||||
Forecast Service Client - Updated for refactored backend structure
|
||||
Handles all API calls to the forecasting service
|
||||
Forecast Service Client for Inter-Service Communication
|
||||
|
||||
Backend structure:
|
||||
- ATOMIC: /forecasting/forecasts (CRUD)
|
||||
- BUSINESS: /forecasting/operations/* (single, multi-day, batch, etc.)
|
||||
- ANALYTICS: /forecasting/analytics/* (predictions-performance)
|
||||
This client provides a high-level API for interacting with the Forecasting Service,
|
||||
which generates demand predictions using Prophet ML algorithm, validates forecast accuracy,
|
||||
and provides enterprise network demand aggregation for multi-location bakeries.
|
||||
|
||||
Key Capabilities:
|
||||
- Forecast Generation: Single product, multi-day, batch forecasting
|
||||
- Real-Time Predictions: On-demand predictions with custom features
|
||||
- Forecast Validation: Compare predictions vs actual sales, track accuracy
|
||||
- Analytics: Prediction performance metrics, historical accuracy trends
|
||||
- Enterprise Aggregation: Network-wide demand forecasting for parent-child hierarchies
|
||||
- Caching: Redis-backed caching for high-performance prediction serving
|
||||
|
||||
Backend Architecture:
|
||||
- ATOMIC: /forecasting/forecasts (CRUD operations on forecast records)
|
||||
- BUSINESS: /forecasting/operations/* (forecast generation, validation)
|
||||
- ANALYTICS: /forecasting/analytics/* (performance metrics, accuracy trends)
|
||||
- ENTERPRISE: /forecasting/enterprise/* (network demand aggregation)
|
||||
|
||||
Enterprise Features (NEW):
|
||||
- Network demand aggregation across all child outlets for centralized production planning
|
||||
- Child contribution tracking (each outlet's % of total network demand)
|
||||
- Redis caching with 1-hour TTL for enterprise forecasts
|
||||
- Subscription gating (requires Enterprise tier)
|
||||
|
||||
Usage Example:
|
||||
```python
|
||||
from shared.clients import get_forecast_client
|
||||
from shared.config.base import get_settings
|
||||
from datetime import date, timedelta
|
||||
|
||||
config = get_settings()
|
||||
client = get_forecast_client(config, calling_service_name="production")
|
||||
|
||||
# Generate 7-day forecast for a product
|
||||
forecast = await client.generate_multi_day_forecast(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=product_id,
|
||||
forecast_date=date.today(),
|
||||
forecast_days=7,
|
||||
include_recommendations=True
|
||||
)
|
||||
|
||||
# Batch forecast for multiple products
|
||||
batch_forecast = await client.generate_batch_forecast(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_ids=[product_id_1, product_id_2],
|
||||
forecast_date=date.today(),
|
||||
forecast_days=7
|
||||
)
|
||||
|
||||
# Validate forecasts against actual sales
|
||||
validation = await client.validate_forecasts(
|
||||
tenant_id=tenant_id,
|
||||
date=date.today() - timedelta(days=1)
|
||||
)
|
||||
|
||||
# Get predictions for a specific date (from cache or DB)
|
||||
predictions = await client.get_predictions_for_date(
|
||||
tenant_id=tenant_id,
|
||||
target_date=date.today()
|
||||
)
|
||||
```
|
||||
|
||||
Service Architecture:
|
||||
- Base URL: Configured via FORECASTING_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
|
||||
- Caching: 24-hour TTL for standard forecasts, 1-hour TTL for enterprise aggregations
|
||||
|
||||
ML Model Details:
|
||||
- Algorithm: Facebook Prophet (time series forecasting)
|
||||
- Features: 20+ temporal, weather, traffic, holiday, POI features
|
||||
- Accuracy: 15-25% MAPE (Mean Absolute Percentage Error)
|
||||
- Training: Weekly retraining via orchestrator automation
|
||||
- Confidence Intervals: 95% confidence bounds (yhat_lower, yhat_upper)
|
||||
|
||||
Related Services:
|
||||
- Production Service: Uses forecasts for production planning
|
||||
- Procurement Service: Uses forecasts for ingredient ordering
|
||||
- Orchestrator Service: Triggers daily forecast generation, displays network forecasts on enterprise dashboard
|
||||
- Tenant Service: Validates hierarchy for enterprise aggregation
|
||||
- Distribution Service: Network forecasts inform capacity planning
|
||||
|
||||
For more details, see services/forecasting/README.md
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -329,3 +409,9 @@ class ForecastServiceClient(BaseServiceClient):
|
||||
forecast_days=1
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
def create_forecast_client(config: BaseServiceSettings, service_name: str = "unknown") -> ForecastServiceClient:
|
||||
"""Create a forecast service client (backward compatibility)"""
|
||||
return ForecastServiceClient(config, service_name)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
158
shared/clients/subscription_client.py
Normal file
158
shared/clients/subscription_client.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Subscription Service Client
|
||||
Client for interacting with subscription service functionality
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import Depends
|
||||
|
||||
from shared.database.base import create_database_manager
|
||||
from app.repositories.subscription_repository import SubscriptionRepository
|
||||
from app.models.tenants import Subscription, Tenant
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
from shared.subscription.plans import SubscriptionTier
|
||||
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SubscriptionServiceClient:
|
||||
"""Client for subscription service operations"""
|
||||
|
||||
def __init__(self, database_manager=None):
|
||||
self.database_manager = database_manager or create_database_manager()
|
||||
|
||||
async def get_subscription(self, tenant_id: str) -> Dict[str, Any]:
|
||||
"""Get subscription details for a tenant"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
subscription_repo = SubscriptionRepository(Subscription, session)
|
||||
subscription = await subscription_repo.get_active_subscription(tenant_id)
|
||||
|
||||
if not subscription:
|
||||
# Return default starter subscription if none found
|
||||
return {
|
||||
'id': None,
|
||||
'tenant_id': tenant_id,
|
||||
'plan': SubscriptionTier.STARTER.value,
|
||||
'status': 'active',
|
||||
'monthly_price': 0,
|
||||
'max_users': 5,
|
||||
'max_locations': 1,
|
||||
'max_products': 50,
|
||||
'features': {}
|
||||
}
|
||||
|
||||
return {
|
||||
'id': str(subscription.id) if subscription.id else None,
|
||||
'tenant_id': tenant_id,
|
||||
'plan': subscription.plan,
|
||||
'status': subscription.status,
|
||||
'monthly_price': subscription.monthly_price,
|
||||
'max_users': subscription.max_users,
|
||||
'max_locations': subscription.max_locations,
|
||||
'max_products': subscription.max_products,
|
||||
'features': subscription.features or {}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get subscription", tenant_id=tenant_id, error=str(e))
|
||||
raise
|
||||
|
||||
async def update_subscription_plan(self, tenant_id: str, new_plan: str) -> Dict[str, Any]:
|
||||
"""Update subscription plan for a tenant"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
subscription_repo = SubscriptionRepository(Subscription, session)
|
||||
|
||||
# Get existing subscription
|
||||
existing_subscription = await subscription_repo.get_active_subscription(tenant_id)
|
||||
|
||||
if existing_subscription:
|
||||
# Update the existing subscription
|
||||
updated_subscription = await subscription_repo.update_subscription(
|
||||
existing_subscription.id,
|
||||
{'plan': new_plan}
|
||||
)
|
||||
else:
|
||||
# Create a new subscription if none exists
|
||||
updated_subscription = await subscription_repo.create_subscription({
|
||||
'tenant_id': tenant_id,
|
||||
'plan': new_plan,
|
||||
'status': 'active',
|
||||
'created_at': None # Let the database set this
|
||||
})
|
||||
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
'id': str(updated_subscription.id),
|
||||
'tenant_id': tenant_id,
|
||||
'plan': updated_subscription.plan,
|
||||
'status': updated_subscription.status
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to update subscription plan", tenant_id=tenant_id, new_plan=new_plan, error=str(e))
|
||||
raise
|
||||
|
||||
async def create_child_subscription(self, child_tenant_id: str, parent_tenant_id: str) -> Dict[str, Any]:
|
||||
"""Create a child subscription inheriting from parent"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
subscription_repo = SubscriptionRepository(Subscription, session)
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
|
||||
# Get parent subscription to inherit plan
|
||||
parent_subscription = await subscription_repo.get_active_subscription(parent_tenant_id)
|
||||
|
||||
if not parent_subscription:
|
||||
# If parent has no subscription, create child with starter plan
|
||||
plan = SubscriptionTier.STARTER.value
|
||||
else:
|
||||
plan = parent_subscription.plan
|
||||
|
||||
# Create subscription for child tenant
|
||||
child_subscription = await subscription_repo.create_subscription({
|
||||
'tenant_id': child_tenant_id,
|
||||
'plan': plan,
|
||||
'status': 'active',
|
||||
'created_at': None # Let the database set this
|
||||
})
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Update the child tenant's subscription tier
|
||||
await tenant_repo.update_tenant(child_tenant_id, {
|
||||
'subscription_tier': plan
|
||||
})
|
||||
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
'id': str(child_subscription.id),
|
||||
'tenant_id': child_tenant_id,
|
||||
'plan': child_subscription.plan,
|
||||
'status': child_subscription.status
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to create child subscription",
|
||||
child_tenant_id=child_tenant_id,
|
||||
parent_tenant_id=parent_tenant_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
async def get_subscription_by_tenant(self, tenant_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get subscription by tenant ID"""
|
||||
return await self.get_subscription(tenant_id)
|
||||
|
||||
async def get_tenant_subscription_tier(self, tenant_id: str) -> str:
|
||||
"""Get the subscription tier for a tenant"""
|
||||
subscription = await self.get_subscription(tenant_id)
|
||||
return subscription.get('plan', SubscriptionTier.STARTER.value)
|
||||
|
||||
|
||||
# Dependency function for FastAPI
|
||||
async def get_subscription_service_client() -> SubscriptionServiceClient:
|
||||
"""FastAPI dependency for subscription service client"""
|
||||
return SubscriptionServiceClient()
|
||||
@@ -1,11 +1,76 @@
|
||||
# shared/clients/tenant_client.py
|
||||
"""
|
||||
Tenant Service Client for Inter-Service Communication
|
||||
Provides access to tenant settings and configuration from other services
|
||||
|
||||
This client provides a high-level API for interacting with the Tenant Service,
|
||||
which manages tenant metadata, settings, hierarchical relationships (parent-child),
|
||||
and multi-location support for enterprise bakery networks.
|
||||
|
||||
Key Capabilities:
|
||||
- Tenant Management: Get, create, update tenant records
|
||||
- Settings Management: Category-specific settings (procurement, inventory, production, etc.)
|
||||
- Enterprise Hierarchy: Parent-child tenant relationships for multi-location networks
|
||||
- Tenant Locations: Physical location management (central_production, retail_outlet)
|
||||
- Subscription Management: Subscription tier and quota validation
|
||||
- Multi-Tenancy: Tenant isolation and access control
|
||||
|
||||
Enterprise Hierarchy Features:
|
||||
- get_child_tenants(): Fetch all child outlets for a parent (central bakery)
|
||||
- get_parent_tenant(): Get parent tenant from child outlet
|
||||
- get_tenant_hierarchy(): Get complete hierarchy path and metadata
|
||||
- get_tenant_locations(): Get all physical locations for a tenant
|
||||
- Supports 3 tenant types: standalone, parent, child
|
||||
|
||||
Usage Example:
|
||||
```python
|
||||
from shared.clients import create_tenant_client
|
||||
from shared.config.base import get_settings
|
||||
|
||||
config = get_settings()
|
||||
client = create_tenant_client(config)
|
||||
|
||||
# Get parent tenant and all children
|
||||
parent = await client.get_tenant(parent_tenant_id)
|
||||
children = await client.get_child_tenants(parent_tenant_id)
|
||||
|
||||
# Get hierarchy information
|
||||
hierarchy = await client.get_tenant_hierarchy(tenant_id)
|
||||
# Returns: {tenant_type: 'parent', hierarchy_path: 'parent_id', child_count: 3}
|
||||
|
||||
# Get physical locations
|
||||
locations = await client.get_tenant_locations(parent_tenant_id)
|
||||
# Returns: [{location_type: 'central_production', ...}, ...]
|
||||
|
||||
# Get category settings
|
||||
procurement_settings = await client.get_procurement_settings(tenant_id)
|
||||
```
|
||||
|
||||
Settings Categories:
|
||||
- procurement: Min/max order quantities, lead times, reorder points
|
||||
- inventory: FIFO settings, expiry thresholds, temperature monitoring
|
||||
- production: Batch sizes, quality control, equipment settings
|
||||
- supplier: Payment terms, delivery preferences
|
||||
- pos: POS integration settings
|
||||
- order: Order fulfillment rules
|
||||
- notification: Alert preferences
|
||||
|
||||
Service Architecture:
|
||||
- Base URL: Configured via TENANT_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:
|
||||
- Distribution Service: Uses tenant locations for delivery route planning
|
||||
- Forecasting Service: Uses hierarchy for network demand aggregation
|
||||
- Procurement Service: Validates parent-child for internal transfers
|
||||
- Orchestrator Service: Enterprise dashboard queries hierarchy data
|
||||
|
||||
For more details, see services/tenant/README.md
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, List
|
||||
from uuid import UUID
|
||||
from shared.clients.base_service_client import BaseServiceClient
|
||||
from shared.config.base import BaseServiceSettings
|
||||
@@ -230,6 +295,116 @@ class TenantServiceClient(BaseServiceClient):
|
||||
logger.error("Error getting active tenants", error=str(e))
|
||||
return []
|
||||
|
||||
# ================================================================
|
||||
# ENTERPRISE TIER METHODS
|
||||
# ================================================================
|
||||
|
||||
async def get_child_tenants(self, parent_tenant_id: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get all child tenants for a parent tenant
|
||||
|
||||
Args:
|
||||
parent_tenant_id: Parent tenant ID
|
||||
|
||||
Returns:
|
||||
List of child tenant dictionaries
|
||||
"""
|
||||
try:
|
||||
result = await self.get(f"tenants/{parent_tenant_id}/children", tenant_id=parent_tenant_id)
|
||||
if result:
|
||||
logger.info("Retrieved child tenants",
|
||||
parent_tenant_id=parent_tenant_id,
|
||||
child_count=len(result) if isinstance(result, list) else 0)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error getting child tenants",
|
||||
error=str(e), parent_tenant_id=parent_tenant_id)
|
||||
return None
|
||||
|
||||
async def get_tenant_children_count(self, tenant_id: str) -> int:
|
||||
"""
|
||||
Get count of child tenants for a parent tenant
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID to check
|
||||
|
||||
Returns:
|
||||
Number of child tenants (0 if not a parent)
|
||||
"""
|
||||
try:
|
||||
children = await self.get_child_tenants(tenant_id)
|
||||
return len(children) if children else 0
|
||||
except Exception as e:
|
||||
logger.error("Error getting child tenant count",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return 0
|
||||
|
||||
async def get_parent_tenant(self, child_tenant_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get parent tenant for a child tenant
|
||||
|
||||
Args:
|
||||
child_tenant_id: Child tenant ID
|
||||
|
||||
Returns:
|
||||
Parent tenant dictionary
|
||||
"""
|
||||
try:
|
||||
result = await self.get(f"tenants/{child_tenant_id}/parent", tenant_id=child_tenant_id)
|
||||
if result:
|
||||
logger.info("Retrieved parent tenant",
|
||||
child_tenant_id=child_tenant_id,
|
||||
parent_tenant_id=result.get('id'))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error getting parent tenant",
|
||||
error=str(e), child_tenant_id=child_tenant_id)
|
||||
return None
|
||||
|
||||
async def get_tenant_hierarchy(self, tenant_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get complete tenant hierarchy information
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID to get hierarchy for
|
||||
|
||||
Returns:
|
||||
Hierarchy information dictionary
|
||||
"""
|
||||
try:
|
||||
result = await self.get("hierarchy", tenant_id=tenant_id)
|
||||
if result:
|
||||
logger.info("Retrieved tenant hierarchy",
|
||||
tenant_id=tenant_id,
|
||||
hierarchy_type=result.get('tenant_type'))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error getting tenant hierarchy",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_tenant_locations(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get all locations for a tenant
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
|
||||
Returns:
|
||||
List of tenant location dictionaries
|
||||
"""
|
||||
try:
|
||||
result = await self.get("locations", tenant_id=tenant_id)
|
||||
if result:
|
||||
logger.info("Retrieved tenant locations",
|
||||
tenant_id=tenant_id,
|
||||
location_count=len(result) if isinstance(result, list) else 0)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error getting tenant locations",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# UTILITY METHODS
|
||||
# ================================================================
|
||||
|
||||
Reference in New Issue
Block a user