New enterprise feature
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# ================================================================
|
||||
|
||||
@@ -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"))
|
||||
|
||||
49
shared/config/feature_flags.py
Normal file
49
shared/config/feature_flags.py
Normal 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"]
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user