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

@@ -353,7 +353,8 @@ def extract_user_from_headers(request: Request) -> Optional[Dict[str, Any]]:
"tenant_id": request.headers.get("x-tenant-id"),
"permissions": request.headers.get("X-User-Permissions", "").split(",") if request.headers.get("X-User-Permissions") else [],
"full_name": request.headers.get("x-user-full-name", ""),
"subscription_tier": request.headers.get("x-subscription-tier", "")
"subscription_tier": request.headers.get("x-subscription-tier", ""),
"is_demo": request.headers.get("x-is-demo", "").lower() == "true"
}
# ✅ ADD THIS: Handle service tokens properly

View File

@@ -73,21 +73,26 @@ class TenantAccessManager:
response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/access/{user_id}"
)
has_access = response.status_code == 200
# If direct access check fails, check hierarchical access
if not has_access:
hierarchical_access = await self._check_hierarchical_access(user_id, tenant_id)
has_access = hierarchical_access
# Cache result (5 minutes)
if self.redis_client:
try:
await self.redis_client.setex(cache_key, 300, "true" if has_access else "false")
except Exception as cache_error:
logger.warning(f"Cache set failed: {cache_error}")
logger.debug(f"Tenant access check",
user_id=user_id,
tenant_id=tenant_id,
logger.debug(f"Tenant access check",
user_id=user_id,
tenant_id=tenant_id,
has_access=has_access)
return has_access
except asyncio.TimeoutError:
@@ -102,17 +107,193 @@ class TenantAccessManager:
logger.error(f"Gateway tenant access verification failed: {e}")
# Fail open for availability (let service handle detailed check)
return True
async def _check_hierarchical_access(self, user_id: str, tenant_id: str) -> bool:
"""
Check if user has hierarchical access (parent tenant access to child)
Args:
user_id: User ID to verify
tenant_id: Target tenant ID to check access for
Returns:
bool: True if user has hierarchical access to the tenant
"""
try:
async with httpx.AsyncClient(timeout=3.0) as client:
response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/hierarchy"
)
if response.status_code == 200:
hierarchy_data = response.json()
parent_tenant_id = hierarchy_data.get("parent_tenant_id")
# If this is a child tenant, check if user has access to parent
if parent_tenant_id:
# Check if user has access to parent tenant
parent_access = await self._check_parent_access(user_id, parent_tenant_id)
if parent_access:
# For aggregated data only, allow parent access to child
# Detailed child data requires direct access
user_role = await self.get_user_role_in_tenant(user_id, parent_tenant_id)
if user_role in ["owner", "admin", "network_admin"]:
return True
return False
except Exception as e:
logger.error(f"Failed to check hierarchical access: {e}")
return False
async def _check_parent_access(self, user_id: str, parent_tenant_id: str) -> bool:
"""
Check if user has access to parent tenant (owner, admin, or network_admin role)
Args:
user_id: User ID
parent_tenant_id: Parent tenant ID
Returns:
bool: True if user has access to parent tenant
"""
user_role = await self.get_user_role_in_tenant(user_id, parent_tenant_id)
return user_role in ["owner", "admin", "network_admin"]
async def verify_hierarchical_access(self, user_id: str, tenant_id: str) -> dict:
"""
Verify hierarchical access and return access type and permissions
Args:
user_id: User ID
tenant_id: Target tenant ID
Returns:
dict: Access information including access_type, can_view_children, etc.
"""
# First check direct access
direct_access = await self._check_direct_access(user_id, tenant_id)
if direct_access:
return {
"access_type": "direct",
"has_access": True,
"can_view_children": False,
"tenant_id": tenant_id
}
# Check if this is a child tenant and user has parent access
hierarchy_info = await self._get_tenant_hierarchy(tenant_id)
if hierarchy_info and hierarchy_info.get("parent_tenant_id"):
parent_tenant_id = hierarchy_info["parent_tenant_id"]
parent_access = await self._check_parent_access(user_id, parent_tenant_id)
if parent_access:
user_role = await self.get_user_role_in_tenant(user_id, parent_tenant_id)
# Network admins have full access across entire hierarchy
if user_role == "network_admin":
return {
"access_type": "hierarchical",
"has_access": True,
"tenant_id": tenant_id,
"parent_tenant_id": parent_tenant_id,
"is_network_admin": True,
"can_view_children": True
}
# Regular admins have read-only access to children aggregated data
elif user_role in ["owner", "admin"]:
return {
"access_type": "hierarchical",
"has_access": True,
"tenant_id": tenant_id,
"parent_tenant_id": parent_tenant_id,
"is_network_admin": False,
"can_view_children": True # Can view aggregated data, not detailed
}
return {
"access_type": "none",
"has_access": False,
"tenant_id": tenant_id,
"can_view_children": False
}
async def _check_direct_access(self, user_id: str, tenant_id: str) -> bool:
"""
Check direct access to tenant (without hierarchy)
"""
try:
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/access/{user_id}"
)
return response.status_code == 200
except Exception as e:
logger.error(f"Failed to check direct access: {e}")
return False
async def _get_tenant_hierarchy(self, tenant_id: str) -> dict:
"""
Get tenant hierarchy information
Args:
tenant_id: Tenant ID
Returns:
dict: Hierarchy information
"""
try:
async with httpx.AsyncClient(timeout=3.0) as client:
response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/hierarchy"
)
if response.status_code == 200:
return response.json()
return {}
except Exception as e:
logger.error(f"Failed to get tenant hierarchy: {e}")
return {}
async def get_accessible_tenants_hierarchy(self, user_id: str) -> list:
"""
Get all tenants a user has access to, organized in hierarchy
Args:
user_id: User ID
Returns:
list: List of tenants with hierarchy structure
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/users/{user_id}/hierarchy"
)
if response.status_code == 200:
tenants = response.json()
logger.debug(f"Retrieved user tenants with hierarchy",
user_id=user_id,
tenant_count=len(tenants))
return tenants
else:
logger.warning(f"Failed to get user tenants hierarchy: {response.status_code}")
return []
except Exception as e:
logger.error(f"Failed to get user tenants hierarchy: {e}")
return []
async def get_user_role_in_tenant(self, user_id: str, tenant_id: str) -> Optional[str]:
"""
Get user's role within a specific tenant
Args:
user_id: User ID
tenant_id: Tenant ID
Returns:
Optional[str]: User's role in tenant (owner, admin, manager, user) or None
Optional[str]: User's role in tenant (owner, admin, manager, user, network_admin) or None
"""
try:
async with httpx.AsyncClient(timeout=3.0) as client:
@@ -122,14 +303,14 @@ class TenantAccessManager:
if response.status_code == 200:
data = response.json()
role = data.get("role")
logger.debug(f"User role in tenant",
user_id=user_id,
tenant_id=tenant_id,
logger.debug(f"User role in tenant",
user_id=user_id,
tenant_id=tenant_id,
role=role)
return role
elif response.status_code == 404:
logger.debug(f"User not found in tenant",
user_id=user_id,
logger.debug(f"User not found in tenant",
user_id=user_id,
tenant_id=tenant_id)
return None
else:

View File

@@ -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

View File

@@ -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"

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)

View File

@@ -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

View 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()

View File

@@ -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
# ================================================================

View File

@@ -42,6 +42,9 @@ INTERNAL_SERVICES: Set[str] = {
"demo-session-service",
"external-service",
# Enterprise services
"distribution-service",
# Legacy/alternative naming (for backwards compatibility)
"data-service", # May be used by older components
}
@@ -198,6 +201,7 @@ class BaseServiceSettings(BaseSettings):
# Service-to-Service Authentication
SERVICE_API_KEY: str = os.getenv("SERVICE_API_KEY", "service-api-key-change-in-production")
INTERNAL_API_KEY: str = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
ENABLE_SERVICE_AUTH: bool = os.getenv("ENABLE_SERVICE_AUTH", "false").lower() == "true"
API_GATEWAY_URL: str = os.getenv("API_GATEWAY_URL", "http://gateway-service:8000")
@@ -238,6 +242,7 @@ class BaseServiceSettings(BaseSettings):
PROCUREMENT_SERVICE_URL: str = os.getenv("PROCUREMENT_SERVICE_URL", "http://procurement-service:8000")
ORCHESTRATOR_SERVICE_URL: str = os.getenv("ORCHESTRATOR_SERVICE_URL", "http://orchestrator-service:8000")
AI_INSIGHTS_SERVICE_URL: str = os.getenv("AI_INSIGHTS_SERVICE_URL", "http://ai-insights-service:8000")
DISTRIBUTION_SERVICE_URL: str = os.getenv("DISTRIBUTION_SERVICE_URL", "http://distribution-service:8000")
# HTTP Client Settings
HTTP_TIMEOUT: int = int(os.getenv("HTTP_TIMEOUT", "30"))

View File

@@ -0,0 +1,49 @@
"""
Feature flags for enterprise tier functionality
"""
import os
from typing import Dict, Any
class FeatureFlags:
"""Enterprise feature flags configuration"""
# Main enterprise tier feature flag
ENABLE_ENTERPRISE_TIER = os.getenv("ENABLE_ENTERPRISE_TIER", "true").lower() == "true"
# Internal transfer feature flag
ENABLE_INTERNAL_TRANSFERS = os.getenv("ENABLE_INTERNAL_TRANSFERS", "true").lower() == "true"
# Distribution service feature flag
ENABLE_DISTRIBUTION_SERVICE = os.getenv("ENABLE_DISTRIBUTION_SERVICE", "true").lower() == "true"
# Network dashboard feature flag
ENABLE_NETWORK_DASHBOARD = os.getenv("ENABLE_NETWORK_DASHBOARD", "true").lower() == "true"
# Child tenant management feature flag
ENABLE_CHILD_TENANT_MANAGEMENT = os.getenv("ENABLE_CHILD_TENANT_MANAGEMENT", "true").lower() == "true"
# Aggregated forecasting feature flag
ENABLE_AGGREGATED_FORECASTING = os.getenv("ENABLE_AGGREGATED_FORECASTING", "true").lower() == "true"
@classmethod
def get_all_flags(cls) -> Dict[str, Any]:
"""Get all feature flags as a dictionary"""
return {
'ENABLE_ENTERPRISE_TIER': cls.ENABLE_ENTERPRISE_TIER,
'ENABLE_INTERNAL_TRANSFERS': cls.ENABLE_INTERNAL_TRANSFERS,
'ENABLE_DISTRIBUTION_SERVICE': cls.ENABLE_DISTRIBUTION_SERVICE,
'ENABLE_NETWORK_DASHBOARD': cls.ENABLE_NETWORK_DASHBOARD,
'ENABLE_CHILD_TENANT_MANAGEMENT': cls.ENABLE_CHILD_TENANT_MANAGEMENT,
'ENABLE_AGGREGATED_FORECASTING': cls.ENABLE_AGGREGATED_FORECASTING,
}
@classmethod
def is_enabled(cls, flag_name: str) -> bool:
"""Check if a specific feature flag is enabled"""
return getattr(cls, flag_name, False)
# Export the feature flags
__all__ = ["FeatureFlags"]

View File

@@ -122,8 +122,8 @@ class DatabaseManager:
# Don't wrap HTTPExceptions - let them pass through
# Check by type name to avoid import dependencies
exception_type = type(e).__name__
if exception_type in ('HTTPException', 'StarletteHTTPException'):
logger.debug(f"Re-raising HTTPException: {e}", service=self.service_name)
if exception_type in ('HTTPException', 'StarletteHTTPException', 'RequestValidationError', 'ValidationError'):
logger.debug(f"Re-raising {exception_type}: {e}", service=self.service_name)
raise
error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}"

View File

@@ -92,6 +92,12 @@ class QuotaLimits:
SubscriptionTier.ENTERPRISE: None, # Unlimited
}
MAX_CHILD_TENANTS = {
SubscriptionTier.STARTER: 0,
SubscriptionTier.PROFESSIONAL: 0,
SubscriptionTier.ENTERPRISE: 50, # Default limit for enterprise tier
}
# ===== ML & Analytics Quotas (Daily Limits) =====
TRAINING_JOBS_PER_DAY = {
SubscriptionTier.STARTER: 1,
@@ -296,6 +302,12 @@ class PlanFeatures:
'production_distribution', # NEW: Hero feature - Central production → multi-store distribution
'centralized_dashboard', # NEW: Hero feature - Single control panel for all operations
'multi_tenant_management',
'parent_child_tenants', # NEW: Enterprise tier feature - hierarchical tenant model
'internal_transfers', # NEW: Internal PO transfers between parent/child
'distribution_management', # NEW: Internal transfer management
'transfer_pricing', # NEW: Cost-based transfer pricing
'centralized_demand_aggregation', # NEW: Aggregate demand from all child tenants
'multi_location_dashboard', # NEW: Dashboard spanning multiple locations
# Advanced Integration
'full_api_access',
@@ -360,6 +372,40 @@ class PlanFeatures:
feature in PlanFeatures.ENTERPRISE_FEATURES
)
@staticmethod
def validate_tenant_access(tier: str, tenant_type: str) -> bool:
"""
Validate tenant type is allowed for subscription tier
Args:
tier: Subscription tier (starter, professional, enterprise)
tenant_type: Tenant type (standalone, parent, child)
Returns:
bool: True if tenant type is allowed for this tier
"""
tier_enum = SubscriptionTier(tier.lower())
# Only enterprise can have parent/child hierarchy
if tenant_type in ["parent", "child"]:
return tier_enum == SubscriptionTier.ENTERPRISE
# Standalone tenants allowed for all tiers
return tenant_type == "standalone"
@staticmethod
def validate_internal_transfers(tier: str) -> bool:
"""
Check if tier can use internal transfers
Args:
tier: Subscription tier
Returns:
bool: True if tier has access to internal transfers
"""
return PlanFeatures.has_feature(tier, "internal_transfers")
# ============================================================================
# FEATURE DISPLAY CONFIGURATION (User-Facing)