Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

297
shared/clients/__init__.py Executable file
View File

@@ -0,0 +1,297 @@
# shared/clients/__init__.py
"""
Service Client Factory and Convenient Imports
Provides easy access to all service clients
"""
from .base_service_client import BaseServiceClient, ServiceAuthenticator
from .auth_client import AuthServiceClient
from .training_client import TrainingServiceClient
from .sales_client import SalesServiceClient
from .external_client import ExternalServiceClient
from .forecast_client import ForecastServiceClient
from .inventory_client import InventoryServiceClient
from .orders_client import OrdersServiceClient
from .production_client import ProductionServiceClient
from .recipes_client import RecipesServiceClient
from .suppliers_client import SuppliersServiceClient
from .tenant_client import TenantServiceClient
from .ai_insights_client import AIInsightsClient
from .alerts_client import AlertsServiceClient
from .alert_processor_client import AlertProcessorClient, get_alert_processor_client
from .procurement_client import ProcurementServiceClient
from .distribution_client import DistributionServiceClient
# Import config
from shared.config.base import BaseServiceSettings
# Cache clients to avoid recreating them
_client_cache = {}
def get_training_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> TrainingServiceClient:
"""Get or create a training service client"""
if config is None:
from app.core.config import settings as config
cache_key = f"training_{service_name}"
if cache_key not in _client_cache:
_client_cache[cache_key] = TrainingServiceClient(config, service_name)
return _client_cache[cache_key]
def get_sales_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> SalesServiceClient:
"""Get or create a sales service client"""
if config is None:
from app.core.config import settings as config
cache_key = f"sales_{service_name}"
if cache_key not in _client_cache:
_client_cache[cache_key] = SalesServiceClient(config, service_name)
return _client_cache[cache_key]
def get_external_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> ExternalServiceClient:
"""Get or create an external service client"""
if config is None:
from app.core.config import settings as config
cache_key = f"external_{service_name}"
if cache_key not in _client_cache:
_client_cache[cache_key] = ExternalServiceClient(config, service_name)
return _client_cache[cache_key]
def get_forecast_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> ForecastServiceClient:
"""Get or create a forecast service client"""
if config is None:
from app.core.config import settings as config
cache_key = f"forecast_{service_name}"
if cache_key not in _client_cache:
_client_cache[cache_key] = ForecastServiceClient(config, service_name)
return _client_cache[cache_key]
def get_inventory_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> InventoryServiceClient:
"""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, service_name)
return _client_cache[cache_key]
def get_orders_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> OrdersServiceClient:
"""Get or create an orders service client"""
if config is None:
from app.core.config import settings as config
cache_key = f"orders_{service_name}"
if cache_key not in _client_cache:
_client_cache[cache_key] = OrdersServiceClient(config)
return _client_cache[cache_key]
def get_production_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> ProductionServiceClient:
"""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, 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, service_name)
return _client_cache[cache_key]
def get_suppliers_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> SuppliersServiceClient:
"""Get or create a suppliers service client"""
if config is None:
from app.core.config import settings as config
cache_key = f"suppliers_{service_name}"
if cache_key not in _client_cache:
_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:
"""Get or create an alerts service client"""
if config is None:
from app.core.config import settings as config
cache_key = f"alerts_{service_name}"
if cache_key not in _client_cache:
_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]
def get_distribution_client(config: BaseServiceSettings = None, service_name: str = "unknown") -> DistributionServiceClient:
"""Get or create a distribution service client"""
if config is None:
from app.core.config import settings as config
cache_key = f"distribution_{service_name}"
if cache_key not in _client_cache:
_client_cache[cache_key] = DistributionServiceClient(config, service_name)
return _client_cache[cache_key]
# Note: get_alert_processor_client is already defined in alert_processor_client.py
# and imported above, so we don't need to redefine it here
class ServiceClients:
"""Convenient wrapper for all service clients"""
def __init__(self, config: BaseServiceSettings = None, service_name: str = "unknown"):
self.service_name = service_name
self.config = config or self._get_default_config()
# Initialize clients lazily
self._training_client = None
self._sales_client = None
self._external_client = None
self._forecast_client = None
self._inventory_client = None
self._orders_client = None
self._production_client = None
self._recipes_client = None
self._suppliers_client = None
def _get_default_config(self):
"""Get default config from app settings"""
try:
from app.core.config import settings
return settings
except ImportError:
raise ImportError("Could not import app config. Please provide config explicitly.")
@property
def training(self) -> TrainingServiceClient:
"""Get training service client"""
if self._training_client is None:
self._training_client = get_training_client(self.config, self.service_name)
return self._training_client
@property
def sales(self) -> SalesServiceClient:
"""Get sales service client"""
if self._sales_client is None:
self._sales_client = get_sales_client(self.config, self.service_name)
return self._sales_client
@property
def external(self) -> ExternalServiceClient:
"""Get external service client"""
if self._external_client is None:
self._external_client = get_external_client(self.config, self.service_name)
return self._external_client
@property
def forecast(self) -> ForecastServiceClient:
"""Get forecast service client"""
if self._forecast_client is None:
self._forecast_client = get_forecast_client(self.config, self.service_name)
return self._forecast_client
@property
def inventory(self) -> InventoryServiceClient:
"""Get inventory service client"""
if self._inventory_client is None:
self._inventory_client = get_inventory_client(self.config, self.service_name)
return self._inventory_client
@property
def orders(self) -> OrdersServiceClient:
"""Get orders service client"""
if self._orders_client is None:
self._orders_client = get_orders_client(self.config, self.service_name)
return self._orders_client
@property
def production(self) -> ProductionServiceClient:
"""Get production service client"""
if self._production_client is None:
self._production_client = get_production_client(self.config, self.service_name)
return self._production_client
@property
def recipes(self) -> RecipesServiceClient:
"""Get recipes service client"""
if self._recipes_client is None:
self._recipes_client = get_recipes_client(self.config, self.service_name)
return self._recipes_client
@property
def suppliers(self) -> SuppliersServiceClient:
"""Get suppliers service client"""
if self._suppliers_client is None:
self._suppliers_client = get_suppliers_client(self.config, self.service_name)
return self._suppliers_client
# Convenience function to get all clients
def get_service_clients(config: BaseServiceSettings = None, service_name: str = "unknown") -> ServiceClients:
"""Get a wrapper with all service clients"""
return ServiceClients(config, service_name)
# Export all classes for direct import
__all__ = [
'BaseServiceClient',
'ServiceAuthenticator',
'AuthServiceClient',
'TrainingServiceClient',
'SalesServiceClient',
'ExternalServiceClient',
'ForecastServiceClient',
'InventoryServiceClient',
'OrdersServiceClient',
'ProductionServiceClient',
'RecipesServiceClient',
'SuppliersServiceClient',
'AlertsServiceClient',
'AlertProcessorClient',
'TenantServiceClient',
'DistributionServiceClient',
'ServiceClients',
'get_training_client',
'get_sales_client',
'get_external_client',
'get_forecast_client',
'get_inventory_client',
'get_orders_client',
'get_production_client',
'get_recipes_client',
'get_suppliers_client',
'get_alerts_client',
'get_alert_processor_client',
'get_tenant_client',
'get_procurement_client',
'get_distribution_client',
'get_service_clients',
'create_forecast_client'
]
# Backward compatibility aliases
create_forecast_client = get_forecast_client

View File

@@ -0,0 +1,391 @@
"""
AI Insights Service HTTP Client
Shared client for all services to post and retrieve AI insights
"""
import httpx
from typing import Dict, List, Any, Optional
from uuid import UUID
import structlog
from datetime import datetime
logger = structlog.get_logger()
class AIInsightsClient:
"""
HTTP client for AI Insights Service.
Allows services to post insights, retrieve orchestration-ready insights, and record feedback.
"""
def __init__(self, base_url: str, timeout: int = 30):
"""
Initialize AI Insights client.
Args:
base_url: Base URL of AI Insights Service (e.g., http://ai-insights-service:8000)
timeout: Request timeout in seconds
"""
self.base_url = base_url.rstrip('/')
self.timeout = timeout
self.client = httpx.AsyncClient(timeout=self.timeout)
async def close(self):
"""Close the HTTP client."""
await self.client.aclose()
async def create_insight(
self,
tenant_id: UUID,
insight_data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""
Create a new insight in AI Insights Service.
Args:
tenant_id: Tenant UUID
insight_data: Insight data dictionary with fields:
- type: str (optimization, alert, prediction, recommendation, insight, anomaly)
- priority: str (low, medium, high, critical)
- category: str (forecasting, procurement, production, inventory, etc.)
- title: str
- description: str
- impact_type: str
- impact_value: float
- impact_unit: str
- confidence: int (0-100)
- metrics_json: dict
- actionable: bool
- recommendation_actions: list (optional)
- source_service: str
- source_model: str (optional)
Returns:
Created insight dict or None if failed
"""
url = f"{self.base_url}/api/v1/tenants/{tenant_id}/insights"
try:
# Ensure tenant_id is in the data
insight_data['tenant_id'] = str(tenant_id)
response = await self.client.post(url, json=insight_data)
if response.status_code == 201:
logger.info(
"Insight created successfully",
tenant_id=str(tenant_id),
insight_title=insight_data.get('title')
)
return response.json()
else:
logger.error(
"Failed to create insight",
status_code=response.status_code,
response=response.text,
insight_title=insight_data.get('title')
)
return None
except Exception as e:
logger.error(
"Error creating insight",
error=str(e),
tenant_id=str(tenant_id)
)
return None
async def create_insights_bulk(
self,
tenant_id: UUID,
insights: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Create multiple insights in bulk.
Args:
tenant_id: Tenant UUID
insights: List of insight data dictionaries
Returns:
Dictionary with success/failure counts
"""
results = {
'total': len(insights),
'successful': 0,
'failed': 0,
'created_insights': []
}
for insight_data in insights:
result = await self.create_insight(tenant_id, insight_data)
if result:
results['successful'] += 1
results['created_insights'].append(result)
else:
results['failed'] += 1
logger.info(
"Bulk insight creation complete",
total=results['total'],
successful=results['successful'],
failed=results['failed']
)
return results
async def get_insights(
self,
tenant_id: UUID,
filters: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
"""
Get insights for a tenant.
Args:
tenant_id: Tenant UUID
filters: Optional filters:
- category: str
- priority: str
- actionable_only: bool
- min_confidence: int
- page: int
- page_size: int
Returns:
Paginated insights response or None if failed
"""
url = f"{self.base_url}/api/v1/tenants/{tenant_id}/insights"
try:
response = await self.client.get(url, params=filters or {})
if response.status_code == 200:
return response.json()
else:
logger.error(
"Failed to get insights",
status_code=response.status_code
)
return None
except Exception as e:
logger.error("Error getting insights", error=str(e))
return None
async def get_orchestration_ready_insights(
self,
tenant_id: UUID,
target_date: datetime,
min_confidence: int = 70
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
"""
Get insights ready for orchestration workflow.
Args:
tenant_id: Tenant UUID
target_date: Target date for orchestration
min_confidence: Minimum confidence threshold
Returns:
Categorized insights or None if failed:
{
"forecast_adjustments": [...],
"procurement_recommendations": [...],
"production_adjustments": [...],
"inventory_optimization": [...],
"risk_alerts": [...]
}
"""
url = f"{self.base_url}/api/v1/tenants/{tenant_id}/insights/orchestration-ready"
params = {
'target_date': target_date.isoformat(),
'min_confidence': min_confidence
}
try:
response = await self.client.get(url, params=params)
if response.status_code == 200:
return response.json()
else:
logger.error(
"Failed to get orchestration insights",
status_code=response.status_code
)
return None
except Exception as e:
logger.error("Error getting orchestration insights", error=str(e))
return None
async def record_feedback(
self,
tenant_id: UUID,
insight_id: UUID,
feedback_data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""
Record feedback for an applied insight.
Args:
tenant_id: Tenant UUID
insight_id: Insight UUID
feedback_data: Feedback data with fields:
- success: bool
- applied_at: datetime (optional)
- actual_impact_value: float (optional)
- actual_impact_unit: str (optional)
- notes: str (optional)
Returns:
Feedback response or None if failed
"""
url = f"{self.base_url}/api/v1/tenants/{tenant_id}/insights/{insight_id}/feedback"
try:
feedback_data['insight_id'] = str(insight_id)
response = await self.client.post(url, json=feedback_data)
if response.status_code in [200, 201]:
logger.info(
"Feedback recorded",
insight_id=str(insight_id),
success=feedback_data.get('success')
)
return response.json()
else:
logger.error(
"Failed to record feedback",
status_code=response.status_code
)
return None
except Exception as e:
logger.error("Error recording feedback", error=str(e))
return None
async def get_insights_summary(
self,
tenant_id: UUID,
time_period_days: int = 30
) -> Optional[Dict[str, Any]]:
"""
Get aggregate metrics summary for insights.
Args:
tenant_id: Tenant UUID
time_period_days: Time period for metrics (default 30 days)
Returns:
Summary metrics or None if failed
"""
url = f"{self.base_url}/api/v1/tenants/{tenant_id}/insights/metrics/summary"
params = {'time_period_days': time_period_days}
try:
response = await self.client.get(url, params=params)
if response.status_code == 200:
return response.json()
else:
logger.error(
"Failed to get insights summary",
status_code=response.status_code
)
return None
except Exception as e:
logger.error("Error getting insights summary", error=str(e))
return None
async def post_accuracy_metrics(
self,
tenant_id: UUID,
validation_date: datetime,
metrics: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""
Post forecast accuracy metrics to AI Insights Service.
Creates an insight with accuracy validation results.
Args:
tenant_id: Tenant UUID
validation_date: Date the forecasts were validated for
metrics: Dictionary with accuracy metrics:
- overall_mape: Mean Absolute Percentage Error
- overall_rmse: Root Mean Squared Error
- overall_mae: Mean Absolute Error
- products_validated: Number of products validated
- poor_accuracy_products: List of products with MAPE > 30%
Returns:
Created insight or None if failed
"""
mape = metrics.get('overall_mape', 0)
products_validated = metrics.get('products_validated', 0)
poor_count = len(metrics.get('poor_accuracy_products', []))
# Determine priority based on MAPE
if mape > 40:
priority = 'critical'
elif mape > 30:
priority = 'high'
elif mape > 20:
priority = 'medium'
else:
priority = 'low'
# Create insight
insight_data = {
'type': 'insight',
'priority': priority,
'category': 'forecasting',
'title': f'Forecast Accuracy Validation - {validation_date.strftime("%Y-%m-%d")}',
'description': (
f'Validated {products_validated} product forecasts against actual sales. '
f'Overall MAPE: {mape:.2f}%. '
f'{poor_count} products require retraining (MAPE > 30%).'
),
'impact_type': 'accuracy',
'impact_value': mape,
'impact_unit': 'mape_percentage',
'confidence': 100, # Validation is based on actual data
'metrics_json': {
'validation_date': validation_date.isoformat() if hasattr(validation_date, 'isoformat') else str(validation_date),
'overall_mape': mape,
'overall_rmse': metrics.get('overall_rmse', 0),
'overall_mae': metrics.get('overall_mae', 0),
'products_validated': products_validated,
'poor_accuracy_count': poor_count,
'poor_accuracy_products': metrics.get('poor_accuracy_products', [])
},
'actionable': poor_count > 0,
'recommendation_actions': [
f'Retrain models for {poor_count} products with poor accuracy'
] if poor_count > 0 else [],
'source_service': 'forecasting',
'source_model': 'forecast_validation'
}
return await self.create_insight(tenant_id, insight_data)
async def health_check(self) -> bool:
"""
Check if AI Insights Service is healthy.
Returns:
True if healthy, False otherwise
"""
url = f"{self.base_url}/health"
try:
response = await self.client.get(url)
return response.status_code == 200
except Exception as e:
logger.error("AI Insights Service health check failed", error=str(e))
return False

View File

@@ -0,0 +1,220 @@
# shared/clients/alert_processor_client.py
"""
Alert Processor Service Client - Inter-service communication
Handles communication with the alert processor service for alert lifecycle management
"""
import structlog
from typing import Dict, Any, List, Optional
from uuid import UUID
from shared.clients.base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class AlertProcessorClient(BaseServiceClient):
"""Client for communicating with the alert processor service via gateway"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
"""Return the base path for alert processor service APIs"""
return "/api/v1"
# ================================================================
# ALERT LIFECYCLE MANAGEMENT
# ================================================================
async def acknowledge_alerts_by_metadata(
self,
tenant_id: UUID,
alert_type: str,
metadata_filter: Dict[str, Any],
acknowledged_by: Optional[str] = None
) -> Dict[str, Any]:
"""
Acknowledge all active alerts matching alert type and metadata.
Used when user actions trigger alert acknowledgment (e.g., approving a PO).
Args:
tenant_id: Tenant UUID
alert_type: Alert type to filter (e.g., 'po_approval_needed')
metadata_filter: Metadata fields to match (e.g., {'po_id': 'uuid'})
acknowledged_by: Optional user ID who acknowledged
Returns:
{
"success": true,
"acknowledged_count": 2,
"alert_ids": ["uuid1", "uuid2"]
}
"""
try:
payload = {
"alert_type": alert_type,
"metadata_filter": metadata_filter
}
if acknowledged_by:
payload["acknowledged_by"] = acknowledged_by
result = await self.post(
f"tenants/{tenant_id}/alerts/acknowledge-by-metadata",
tenant_id=str(tenant_id),
data=payload
)
if result and result.get("success"):
logger.info(
"Acknowledged alerts by metadata",
tenant_id=str(tenant_id),
alert_type=alert_type,
count=result.get("acknowledged_count", 0),
calling_service=self.calling_service_name
)
return result or {"success": False, "acknowledged_count": 0, "alert_ids": []}
except Exception as e:
logger.error(
"Error acknowledging alerts by metadata",
error=str(e),
tenant_id=str(tenant_id),
alert_type=alert_type,
metadata_filter=metadata_filter,
calling_service=self.calling_service_name
)
return {"success": False, "acknowledged_count": 0, "alert_ids": [], "error": str(e)}
async def resolve_alerts_by_metadata(
self,
tenant_id: UUID,
alert_type: str,
metadata_filter: Dict[str, Any],
resolved_by: Optional[str] = None
) -> Dict[str, Any]:
"""
Resolve all active alerts matching alert type and metadata.
Used when user actions complete an alert's underlying issue (e.g., marking delivery received).
Args:
tenant_id: Tenant UUID
alert_type: Alert type to filter (e.g., 'delivery_overdue')
metadata_filter: Metadata fields to match (e.g., {'po_id': 'uuid'})
resolved_by: Optional user ID who resolved
Returns:
{
"success": true,
"resolved_count": 1,
"alert_ids": ["uuid1"]
}
"""
try:
payload = {
"alert_type": alert_type,
"metadata_filter": metadata_filter
}
if resolved_by:
payload["resolved_by"] = resolved_by
result = await self.post(
f"tenants/{tenant_id}/alerts/resolve-by-metadata",
tenant_id=str(tenant_id),
data=payload
)
if result and result.get("success"):
logger.info(
"Resolved alerts by metadata",
tenant_id=str(tenant_id),
alert_type=alert_type,
count=result.get("resolved_count", 0),
calling_service=self.calling_service_name
)
return result or {"success": False, "resolved_count": 0, "alert_ids": []}
except Exception as e:
logger.error(
"Error resolving alerts by metadata",
error=str(e),
tenant_id=str(tenant_id),
alert_type=alert_type,
metadata_filter=metadata_filter,
calling_service=self.calling_service_name
)
return {"success": False, "resolved_count": 0, "alert_ids": [], "error": str(e)}
async def get_active_alerts(
self,
tenant_id: UUID,
priority_level: Optional[str] = None,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get active alerts for a tenant.
Args:
tenant_id: Tenant UUID
priority_level: Optional priority filter (critical, important, standard, info)
limit: Maximum number of alerts to return
Returns:
List of alert dictionaries
"""
try:
params = {
"status": "active",
"limit": limit
}
if priority_level:
params["priority_level"] = priority_level
result = await self.get(
f"tenants/{tenant_id}/alerts",
tenant_id=str(tenant_id),
params=params
)
alerts = result.get("alerts", []) if isinstance(result, dict) else []
logger.info(
"Retrieved active alerts",
tenant_id=str(tenant_id),
count=len(alerts),
calling_service=self.calling_service_name
)
return alerts
except Exception as e:
logger.error(
"Error fetching active alerts",
error=str(e),
tenant_id=str(tenant_id),
calling_service=self.calling_service_name
)
return []
# Factory function for easy import
def get_alert_processor_client(config: BaseServiceSettings, calling_service_name: str) -> AlertProcessorClient:
"""
Factory function to create an AlertProcessorClient instance.
Args:
config: Service configuration with gateway URL
calling_service_name: Name of the service making the call (for logging)
Returns:
AlertProcessorClient instance
"""
return AlertProcessorClient(config, calling_service_name)

259
shared/clients/alerts_client.py Executable file
View File

@@ -0,0 +1,259 @@
# shared/clients/alerts_client.py
"""
Alerts Service Client for Inter-Service Communication
Provides access to alert processor service from other services
"""
import structlog
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
logger = structlog.get_logger()
class AlertsServiceClient(BaseServiceClient):
"""Client for communicating with the Alert Processor Service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# DASHBOARD METHODS
# ================================================================
async def get_alerts_summary(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Get alerts summary for dashboard health status
Args:
tenant_id: Tenant ID
Returns:
Dict with counts by severity:
{
"total_count": int,
"active_count": int,
"critical_count": int, # Maps to "urgent" severity
"high_count": int,
"medium_count": int,
"low_count": int,
"resolved_count": int,
"acknowledged_count": int
}
"""
try:
# Gateway routes /tenants/{tenant_id}/alerts/... to alert_processor service
return await self.get(
"/alerts/summary",
tenant_id=tenant_id
)
except Exception as e:
logger.error("Error fetching alerts summary", error=str(e), tenant_id=tenant_id)
return None
async def get_critical_alerts(
self,
tenant_id: str,
limit: int = 20
) -> Optional[Dict[str, Any]]:
"""
Get critical/urgent alerts for dashboard
Note: "critical" in dashboard context maps to "urgent" severity in alert_processor
Args:
tenant_id: Tenant ID
limit: Maximum number of alerts to return
Returns:
Dict with:
{
"alerts": [...],
"total": int,
"limit": int,
"offset": int
}
"""
try:
# Gateway routes /tenants/{tenant_id}/alerts/... to alert_processor service
# "critical" in dashboard = "urgent" severity in alert_processor
return await self.get(
"/alerts",
tenant_id=tenant_id,
params={"severity": "urgent", "resolved": False, "limit": limit}
)
except Exception as e:
logger.error("Error fetching critical alerts", error=str(e), tenant_id=tenant_id)
return None
async def get_alerts(
self,
tenant_id: str,
priority_level: Optional[str] = None,
status: Optional[str] = None,
resolved: Optional[bool] = None,
limit: int = 100,
offset: int = 0
) -> Optional[Dict[str, Any]]:
"""
Get alerts with optional filters
Args:
tenant_id: Tenant ID
priority_level: Filter by priority level (critical, important, standard, info)
status: Filter by status (active, resolved, acknowledged, ignored)
resolved: Filter by resolved status (None = all, True = resolved only, False = unresolved only)
limit: Maximum number of alerts
offset: Pagination offset
Returns:
Dict with:
{
"alerts": [...],
"total": int,
"limit": int,
"offset": int
}
"""
try:
params = {"limit": limit, "offset": offset}
if priority_level:
params["priority_level"] = priority_level
if status:
params["status"] = status
if resolved is not None:
params["resolved"] = resolved
return await self.get(
"/alerts",
tenant_id=tenant_id,
params=params
)
except Exception as e:
logger.error("Error fetching alerts",
error=str(e), tenant_id=tenant_id)
return None
async def get_alerts_by_severity(
self,
tenant_id: str,
severity: str,
limit: int = 100,
resolved: Optional[bool] = None
) -> Optional[Dict[str, Any]]:
"""
Get alerts filtered by severity
Args:
tenant_id: Tenant ID
severity: Severity level (low, medium, high, urgent)
limit: Maximum number of alerts
resolved: Filter by resolved status (None = all, True = resolved only, False = unresolved only)
Returns:
Dict with alerts list and metadata
"""
try:
params = {"severity": severity, "limit": limit}
if resolved is not None:
params["resolved"] = resolved
return await self.get(
"/alerts",
tenant_id=tenant_id,
params=params
)
except Exception as e:
logger.error("Error fetching alerts by severity",
error=str(e), severity=severity, tenant_id=tenant_id)
return None
async def get_alert_by_id(
self,
tenant_id: str,
alert_id: str
) -> Optional[Dict[str, Any]]:
"""
Get a specific alert by ID
Args:
tenant_id: Tenant ID
alert_id: Alert UUID
Returns:
Dict with alert details
"""
try:
return await self.get(
f"/alerts/{alert_id}",
tenant_id=tenant_id
)
except Exception as e:
logger.error("Error fetching alert", error=str(e),
alert_id=alert_id, tenant_id=tenant_id)
return None
async def get_dashboard_analytics(
self,
tenant_id: str,
days: int = 7
) -> Optional[Dict[str, Any]]:
"""
Get dashboard analytics including prevented issues and estimated savings
Args:
tenant_id: Tenant ID
days: Number of days to analyze (default: 7)
Returns:
Dict with analytics data:
{
"period_days": int,
"total_alerts": int,
"active_alerts": int,
"ai_handling_rate": float,
"prevented_issues_count": int,
"estimated_savings_eur": float,
"total_financial_impact_at_risk_eur": float,
"priority_distribution": {...},
"type_class_distribution": {...},
"active_by_type_class": {...},
"period_comparison": {...}
}
"""
try:
return await self.get(
"/alerts/analytics/dashboard",
tenant_id=tenant_id,
params={"days": days}
)
except Exception as e:
logger.error("Error fetching dashboard analytics", error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if alerts service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Alerts service health check failed", error=str(e))
return False
# Factory function for dependency injection
def create_alerts_client(config: BaseServiceSettings, calling_service_name: str = "unknown") -> AlertsServiceClient:
"""Create alerts service client instance"""
return AlertsServiceClient(config, calling_service_name)

263
shared/clients/auth_client.py Executable file
View File

@@ -0,0 +1,263 @@
# shared/clients/auth_client.py
"""
Auth Service Client for Inter-Service Communication
Provides methods to interact with the authentication/onboarding service
"""
from typing import Optional, Dict, Any, List
import structlog
from shared.clients.base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class AuthServiceClient(BaseServiceClient):
"""Client for interacting with the Auth Service"""
def __init__(self, config: BaseServiceSettings):
super().__init__("auth", config)
def get_service_base_path(self) -> str:
"""Return the base path for auth service APIs"""
return "/api/v1/auth"
async def get_user_onboarding_progress(self, user_id: str) -> Optional[Dict[str, Any]]:
"""
Get user's onboarding progress including step data
Args:
user_id: User ID to fetch progress for
Returns:
Dict with user progress including steps with data, or None if failed
"""
try:
# Use the service endpoint that accepts user_id as parameter
result = await self.get(f"/users/{user_id}/onboarding/progress")
if result:
logger.info("Retrieved user onboarding progress",
user_id=user_id,
current_step=result.get("current_step"))
return result
else:
logger.warning("No onboarding progress found",
user_id=user_id)
return None
except Exception as e:
logger.error("Failed to get user onboarding progress",
user_id=user_id,
error=str(e))
return None
async def get_user_step_data(self, user_id: str, step_name: str) -> Optional[Dict[str, Any]]:
"""
Get data for a specific onboarding step
Args:
user_id: User ID
step_name: Name of the step (e.g., "user_registered")
Returns:
Step data dictionary or None if not found
"""
try:
progress = await self.get_user_onboarding_progress(user_id)
if not progress:
logger.warning("No progress data returned",
user_id=user_id)
return None
logger.debug("Retrieved progress data",
user_id=user_id,
steps_count=len(progress.get("steps", [])),
current_step=progress.get("current_step"))
# Find the specific step
for step in progress.get("steps", []):
if step.get("step_name") == step_name:
step_data = step.get("data", {})
logger.info("Found step data",
user_id=user_id,
step_name=step_name,
data_keys=list(step_data.keys()) if step_data else [],
has_subscription_plan="subscription_plan" in step_data)
return step_data
logger.warning("Step not found in progress",
user_id=user_id,
step_name=step_name,
available_steps=[s.get("step_name") for s in progress.get("steps", [])])
return None
except Exception as e:
logger.error("Failed to get step data",
user_id=user_id,
step_name=step_name,
error=str(e))
return None
async def get_subscription_plan_from_registration(self, user_id: str) -> str:
"""
Get the subscription plan selected during user registration
Args:
user_id: User ID
Returns:
Plan name (e.g., "starter", "professional", "enterprise") or "starter" as default
"""
try:
step_data = await self.get_user_step_data(user_id, "user_registered")
if step_data and "subscription_plan" in step_data:
plan = step_data["subscription_plan"]
logger.info("Retrieved subscription plan from registration",
user_id=user_id,
plan=plan)
return plan
else:
logger.info("No subscription plan in registration data, using default",
user_id=user_id,
default_plan="starter")
return "starter"
except Exception as e:
logger.warning("Failed to retrieve subscription plan, using default",
user_id=user_id,
error=str(e),
default_plan="starter")
return "starter"
async def create_user_by_owner(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Create a new user account via the auth service (owner/admin only - pilot phase).
This method calls the auth service endpoint that allows tenant owners
to directly create users with passwords during the pilot phase.
Args:
user_data: Dictionary containing:
- email: User email (required)
- full_name: Full name (required)
- password: Password (required)
- phone: Phone number (optional)
- role: User role (optional, default: "user")
- language: Language preference (optional, default: "es")
- timezone: Timezone (optional, default: "Europe/Madrid")
Returns:
Dict with created user data including user ID
Raises:
Exception if user creation fails
"""
try:
logger.info(
"Creating user via auth service",
email=user_data.get("email"),
role=user_data.get("role", "user")
)
result = await self.post("/users/create-by-owner", user_data)
if result and result.get("id"):
logger.info(
"User created successfully via auth service",
user_id=result.get("id"),
email=result.get("email")
)
return result
else:
logger.error("User creation returned no user ID")
raise Exception("User creation failed: No user ID returned")
except Exception as e:
logger.error(
"Failed to create user via auth service",
email=user_data.get("email"),
error=str(e)
)
raise
async def get_user_details(self, user_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed user information including payment details
Args:
user_id: User ID to fetch details for
Returns:
Dict with user details including:
- id, email, full_name, is_active, is_verified
- phone, language, timezone, role
- payment_customer_id, default_payment_method_id
- created_at, last_login, etc.
Returns None if user not found or request fails
"""
try:
logger.info("Fetching user details from auth service",
user_id=user_id)
result = await self.get(f"/users/{user_id}")
if result and result.get("id"):
logger.info("Successfully retrieved user details",
user_id=user_id,
email=result.get("email"),
has_payment_info="payment_customer_id" in result)
return result
else:
logger.warning("No user details found",
user_id=user_id)
return None
except Exception as e:
logger.error("Failed to get user details from auth service",
user_id=user_id,
error=str(e))
return None
async def update_user_tenant_id(self, user_id: str, tenant_id: str) -> bool:
"""
Update the user's tenant_id after tenant registration
Args:
user_id: User ID to update
tenant_id: Tenant ID to link to the user
Returns:
True if successful, False otherwise
"""
try:
logger.info("Updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
result = await self.patch(
f"/users/{user_id}/tenant",
{"tenant_id": tenant_id}
)
if result:
logger.info("Successfully updated user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
return True
else:
logger.warning("Failed to update user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
return False
except Exception as e:
logger.error("Error updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return False

View File

@@ -0,0 +1,438 @@
# shared/clients/base_service_client.py
"""
Base Service Client for Inter-Service Communication
Provides a reusable foundation for all service-to-service API calls
"""
import time
import asyncio
import httpx
import structlog
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List, Union
from urllib.parse import urljoin
from shared.auth.jwt_handler import JWTHandler
from shared.config.base import BaseServiceSettings
from shared.clients.circuit_breaker import CircuitBreaker, CircuitBreakerOpenException
logger = structlog.get_logger()
class ServiceAuthenticator:
"""Handles service-to-service authentication via gateway"""
def __init__(self, service_name: str, config: BaseServiceSettings):
self.service_name = service_name
self.config = config
self.jwt_handler = JWTHandler(config.JWT_SECRET_KEY)
self._cached_token = None
self._token_expires_at = 0
self._cached_tenant_id = None # Track tenant context for cached tokens
async def get_service_token(self, tenant_id: Optional[str] = None) -> str:
"""Get a valid service token, using cache when possible"""
current_time = int(time.time())
# Return cached token if still valid (with 5 min buffer) and tenant context matches
if (self._cached_token and
self._token_expires_at > current_time + 300 and
(tenant_id is None or self._cached_tenant_id == tenant_id)):
return self._cached_token
# Create new service token using unified JWT handler
try:
token = self.jwt_handler.create_service_token(
service_name=self.service_name,
tenant_id=tenant_id
)
# Extract expiration from token for caching
import json
from jose import jwt
payload = jwt.decode(token, self.jwt_handler.secret_key, algorithms=[self.jwt_handler.algorithm], options={"verify_signature": False})
token_expires_at = payload.get("exp", current_time + 3600)
self._cached_token = token
self._token_expires_at = token_expires_at
self._cached_tenant_id = tenant_id # Store tenant context for caching
logger.debug("Created new service token", service=self.service_name, expires_at=token_expires_at, tenant_id=tenant_id)
return token
except Exception as e:
logger.error(f"Failed to create service token: {e}", service=self.service_name)
raise ValueError(f"Service token creation failed: {e}")
def get_request_headers(self, tenant_id: Optional[str] = None) -> Dict[str, str]:
"""Get standard headers for service requests"""
headers = {
"X-Service": f"{self.service_name}-service",
"User-Agent": f"{self.service_name}-service/1.0.0"
}
if tenant_id:
headers["x-tenant-id"] = str(tenant_id)
return headers
class BaseServiceClient(ABC):
"""
Base class for all inter-service communication clients
Provides common functionality for API calls through the gateway
"""
def __init__(self, service_name: str, config: BaseServiceSettings):
self.service_name = service_name
self.config = config
self.gateway_url = config.GATEWAY_URL
self.authenticator = ServiceAuthenticator(service_name, config)
# HTTP client configuration
self.timeout = config.HTTP_TIMEOUT
self.retries = config.HTTP_RETRIES
self.retry_delay = config.HTTP_RETRY_DELAY
# Circuit breaker for fault tolerance
self.circuit_breaker = CircuitBreaker(
service_name=f"{service_name}-client",
failure_threshold=5,
timeout=60,
success_threshold=2
)
@abstractmethod
def get_service_base_path(self) -> str:
"""Return the base path for this service's APIs"""
pass
async def _make_request(
self,
method: str,
endpoint: str,
tenant_id: Optional[str] = None,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[Union[int, httpx.Timeout]] = None
) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
"""
Make an authenticated request to another service via gateway with circuit breaker protection.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint (will be prefixed with service base path)
tenant_id: Optional tenant ID for tenant-scoped requests
data: Request body data (for POST/PUT)
params: Query parameters
headers: Additional headers
timeout: Request timeout override
Returns:
Response data or None if request failed
"""
try:
# Wrap request in circuit breaker
return await self.circuit_breaker.call(
self._do_request,
method,
endpoint,
tenant_id,
data,
params,
headers,
timeout
)
except CircuitBreakerOpenException as e:
logger.error(
"Circuit breaker open - request rejected",
service=self.service_name,
endpoint=endpoint,
error=str(e)
)
return None
except Exception as e:
logger.error(
"Unexpected error in request",
service=self.service_name,
endpoint=endpoint,
error=str(e)
)
return None
async def _do_request(
self,
method: str,
endpoint: str,
tenant_id: Optional[str] = None,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[Union[int, httpx.Timeout]] = None
) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
"""
Internal method to execute HTTP request with retries.
Called by _make_request through circuit breaker.
"""
try:
# Get service token with tenant context for tenant-scoped requests
token = await self.authenticator.get_service_token(tenant_id)
# Build headers
request_headers = self.authenticator.get_request_headers(tenant_id)
request_headers["Authorization"] = f"Bearer {token}"
request_headers["Content-Type"] = "application/json"
# Propagate request ID for distributed tracing if provided
if headers and "X-Request-ID" in headers:
request_headers["X-Request-ID"] = headers["X-Request-ID"]
if headers:
request_headers.update(headers)
# Build URL
base_path = self.get_service_base_path()
if tenant_id:
# For tenant-scoped endpoints
full_endpoint = f"{base_path}/tenants/{tenant_id}/{endpoint.lstrip('/')}"
else:
# For non-tenant endpoints
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:
# Handle different timeout configurations
if isinstance(timeout, httpx.Timeout):
client_timeout = timeout
else:
client_timeout = timeout or self.timeout
async with httpx.AsyncClient(timeout=client_timeout) as client:
response = await client.request(
method=method,
url=url,
json=data,
params=params,
headers=request_headers
)
if response.status_code == 200:
return response.json()
elif response.status_code == 201:
return response.json()
elif response.status_code == 204:
return {} # No content success
elif response.status_code == 401:
# Token might be expired, clear cache and retry once
if attempt == 0:
self.authenticator._cached_token = None
logger.warning("Token expired, retrying with new token")
continue
else:
logger.error("Authentication failed after retry")
return None
elif response.status_code == 404:
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"
try:
error_json = response.json()
error_detail = error_json.get('detail', f"HTTP {response.status_code}")
except:
error_detail = f"HTTP {response.status_code}: {response.text}"
logger.error(f"Request failed: {error_detail}",
url=url, status_code=response.status_code)
return None
except httpx.TimeoutException:
if attempt < self.retries:
logger.warning(f"Request timeout, retrying ({attempt + 1}/{self.retries})")
import asyncio
await asyncio.sleep(self.retry_delay * (2 ** attempt)) # Exponential backoff
continue
else:
logger.error(f"Request timeout after {self.retries} retries", url=url)
return None
except Exception as e:
if attempt < self.retries:
logger.warning(f"Request failed, retrying ({attempt + 1}/{self.retries}): {e}")
import asyncio
await asyncio.sleep(self.retry_delay * (2 ** attempt))
continue
else:
logger.error(f"Request failed after {self.retries} retries: {e}", url=url)
return None
except Exception as e:
logger.error(f"Unexpected error in _make_request: {e}")
return None
async def _make_paginated_request(
self,
endpoint: str,
tenant_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
page_size: int = 1000,
max_pages: int = 100,
timeout: Optional[Union[int, httpx.Timeout]] = None
) -> List[Dict[str, Any]]:
"""
Make paginated GET requests to fetch all records
Handles both direct list and paginated object responses
Args:
endpoint: API endpoint
tenant_id: Optional tenant ID
params: Base query parameters
page_size: Records per page (default 1000)
max_pages: Maximum pages to fetch (safety limit)
timeout: Request timeout override
Returns:
List of all records from all pages
"""
all_records = []
page = 0
base_params = params or {}
logger.info(f"Starting paginated request to {endpoint}",
tenant_id=tenant_id, page_size=page_size)
while page < max_pages:
# Prepare pagination parameters
page_params = base_params.copy()
page_params.update({
"limit": page_size,
"offset": page * page_size
})
logger.debug(f"Fetching page {page + 1} (offset: {page * page_size})",
tenant_id=tenant_id)
# Make request for this page
result = await self._make_request(
"GET",
endpoint,
tenant_id=tenant_id,
params=page_params,
timeout=timeout
)
if result is None:
logger.error(f"Failed to fetch page {page + 1}", tenant_id=tenant_id)
break
# Handle different response formats
if isinstance(result, list):
# Direct list response (no pagination metadata)
records = result
logger.debug(f"Retrieved {len(records)} records from page {page + 1} (direct list)")
if len(records) == 0:
logger.info("No records in response, pagination complete")
break
elif len(records) < page_size:
# Got fewer than requested, this is the last page
all_records.extend(records)
logger.info(f"Final page: retrieved {len(records)} records, total: {len(all_records)}")
break
else:
# Got full page, there might be more
all_records.extend(records)
logger.debug(f"Full page retrieved: {len(records)} records, continuing to next page")
elif isinstance(result, dict):
# Paginated response format
records = result.get('records', result.get('data', []))
total_available = result.get('total', 0)
logger.debug(f"Retrieved {len(records)} records from page {page + 1} (paginated response)")
if not records:
logger.info("No more records found in paginated response")
break
all_records.extend(records)
# Check if we've got all available records
if len(all_records) >= total_available:
logger.info(f"Retrieved all available records: {len(all_records)}/{total_available}")
break
else:
logger.warning(f"Unexpected response format: {type(result)}")
break
page += 1
if page >= max_pages:
logger.warning(f"Reached maximum page limit ({max_pages}), stopping pagination")
logger.info(f"Pagination complete: fetched {len(all_records)} total records",
tenant_id=tenant_id, pages_fetched=page)
return all_records
async def get(self, endpoint: str, tenant_id: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make a GET request"""
return await self._make_request("GET", endpoint, tenant_id=tenant_id, params=params)
async def get_paginated(
self,
endpoint: str,
tenant_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
page_size: int = 1000,
max_pages: int = 100,
timeout: Optional[Union[int, httpx.Timeout]] = None
) -> List[Dict[str, Any]]:
"""Make a paginated GET request to fetch all records"""
return await self._make_paginated_request(
endpoint,
tenant_id=tenant_id,
params=params,
page_size=page_size,
max_pages=max_pages,
timeout=timeout
)
async def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None, tenant_id: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make a POST request with optional query parameters"""
return await self._make_request("POST", endpoint, tenant_id=tenant_id, data=data, params=params)
async def put(self, endpoint: str, data: Dict[str, Any], tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a PUT request"""
return await self._make_request("PUT", endpoint, tenant_id=tenant_id, data=data)
async def patch(self, endpoint: str, data: Dict[str, Any], tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a PATCH request"""
return await self._make_request("PATCH", endpoint, tenant_id=tenant_id, data=data)
async def delete(self, endpoint: str, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a DELETE request"""
return await self._make_request("DELETE", endpoint, tenant_id=tenant_id)

215
shared/clients/circuit_breaker.py Executable file
View File

@@ -0,0 +1,215 @@
"""
Circuit Breaker implementation for inter-service communication
Prevents cascading failures by failing fast when a service is unhealthy
"""
import time
import structlog
from enum import Enum
from typing import Callable, Any, Optional
import asyncio
logger = structlog.get_logger()
class CircuitState(Enum):
"""Circuit breaker states"""
CLOSED = "closed" # Normal operation, requests pass through
OPEN = "open" # Service is failing, reject requests immediately
HALF_OPEN = "half_open" # Testing if service has recovered
class CircuitBreakerOpenException(Exception):
"""Raised when circuit breaker is open and rejects a request"""
pass
class CircuitBreaker:
"""
Circuit breaker pattern implementation for preventing cascading failures.
States:
- CLOSED: Normal operation, all requests pass through
- OPEN: Service is failing, reject all requests immediately
- HALF_OPEN: Testing recovery, allow one request through
Transitions:
- CLOSED -> OPEN: After failure_threshold consecutive failures
- OPEN -> HALF_OPEN: After timeout seconds have passed
- HALF_OPEN -> CLOSED: If test request succeeds
- HALF_OPEN -> OPEN: If test request fails
"""
def __init__(
self,
service_name: str,
failure_threshold: int = 5,
timeout: int = 60,
success_threshold: int = 2
):
"""
Initialize circuit breaker.
Args:
service_name: Name of the service being protected
failure_threshold: Number of consecutive failures before opening circuit
timeout: Seconds to wait before attempting recovery (half-open state)
success_threshold: Consecutive successes needed to close from half-open
"""
self.service_name = service_name
self.failure_threshold = failure_threshold
self.timeout = timeout
self.success_threshold = success_threshold
self.state = CircuitState.CLOSED
self.failure_count = 0
self.success_count = 0
self.last_failure_time: Optional[float] = None
self._lock = asyncio.Lock()
logger.info(
"Circuit breaker initialized",
service=service_name,
failure_threshold=failure_threshold,
timeout=timeout
)
async def call(self, func: Callable, *args, **kwargs) -> Any:
"""
Execute function with circuit breaker protection.
Args:
func: Async function to execute
*args, **kwargs: Arguments to pass to func
Returns:
Result from func
Raises:
CircuitBreakerOpenException: If circuit is open
Exception: Any exception raised by func
"""
async with self._lock:
# Check if circuit should transition to half-open
if self.state == CircuitState.OPEN:
if self._should_attempt_reset():
logger.info(
"Circuit breaker transitioning to half-open",
service=self.service_name
)
self.state = CircuitState.HALF_OPEN
self.success_count = 0
else:
# Circuit is open, reject request
raise CircuitBreakerOpenException(
f"Circuit breaker is OPEN for {self.service_name}. "
f"Service will be retried in {self._time_until_retry():.0f} seconds."
)
# Execute function
try:
result = await func(*args, **kwargs)
await self._on_success()
return result
except Exception as e:
await self._on_failure(e)
raise
def _should_attempt_reset(self) -> bool:
"""Check if enough time has passed to attempt recovery"""
if self.last_failure_time is None:
return True
return time.time() - self.last_failure_time >= self.timeout
def _time_until_retry(self) -> float:
"""Calculate seconds until next retry attempt"""
if self.last_failure_time is None:
return 0.0
elapsed = time.time() - self.last_failure_time
return max(0.0, self.timeout - elapsed)
async def _on_success(self):
"""Handle successful request"""
async with self._lock:
self.failure_count = 0
if self.state == CircuitState.HALF_OPEN:
self.success_count += 1
logger.debug(
"Circuit breaker success in half-open state",
service=self.service_name,
success_count=self.success_count,
success_threshold=self.success_threshold
)
if self.success_count >= self.success_threshold:
logger.info(
"Circuit breaker closing - service recovered",
service=self.service_name
)
self.state = CircuitState.CLOSED
self.success_count = 0
async def _on_failure(self, exception: Exception):
"""Handle failed request"""
async with self._lock:
self.failure_count += 1
self.last_failure_time = time.time()
if self.state == CircuitState.HALF_OPEN:
logger.warning(
"Circuit breaker opening - recovery attempt failed",
service=self.service_name,
error=str(exception)
)
self.state = CircuitState.OPEN
self.success_count = 0
elif self.state == CircuitState.CLOSED:
logger.warning(
"Circuit breaker failure recorded",
service=self.service_name,
failure_count=self.failure_count,
threshold=self.failure_threshold,
error=str(exception)
)
if self.failure_count >= self.failure_threshold:
logger.error(
"Circuit breaker opening - failure threshold reached",
service=self.service_name,
failure_count=self.failure_count
)
self.state = CircuitState.OPEN
def get_state(self) -> str:
"""Get current circuit breaker state"""
return self.state.value
def is_closed(self) -> bool:
"""Check if circuit is closed (normal operation)"""
return self.state == CircuitState.CLOSED
def is_open(self) -> bool:
"""Check if circuit is open (failing fast)"""
return self.state == CircuitState.OPEN
def is_half_open(self) -> bool:
"""Check if circuit is half-open (testing recovery)"""
return self.state == CircuitState.HALF_OPEN
async def reset(self):
"""Manually reset circuit breaker to closed state"""
async with self._lock:
logger.info(
"Circuit breaker manually reset",
service=self.service_name,
previous_state=self.state.value
)
self.state = CircuitState.CLOSED
self.failure_count = 0
self.success_count = 0
self.last_failure_time = None

View File

@@ -0,0 +1,477 @@
"""
Distribution Service Client for Inter-Service Communication
This client provides a high-level API for interacting with the Distribution Service,
which manages delivery routes, shipment tracking, and vehicle routing optimization for
enterprise multi-location bakery networks.
Key Capabilities:
- Generate daily distribution plans using VRP (Vehicle Routing Problem) optimization
- Manage delivery routes with driver assignments and route sequencing
- Track shipments from pending → packed → in_transit → delivered
- Update shipment status with proof of delivery (POD) metadata
- Filter routes and shipments by date range and status
- Setup enterprise distribution for demo sessions
Enterprise Context:
- Designed for parent-child tenant hierarchies (central production + retail outlets)
- Routes optimize deliveries from parent (central bakery) to children (outlets)
- Integrates with Procurement Service (internal transfer POs) and Inventory Service (stock transfers)
- Publishes shipment.delivered events for inventory ownership transfer
Usage Example:
```python
from shared.clients import create_distribution_client
from shared.config.base import get_settings
config = get_settings()
client = create_distribution_client(config, service_name="orchestrator")
# Generate daily distribution plan
plan = await client.generate_daily_distribution_plan(
tenant_id=parent_tenant_id,
target_date=date.today(),
vehicle_capacity_kg=1000.0
)
# Get active delivery routes
routes = await client.get_delivery_routes(
tenant_id=parent_tenant_id,
status="in_progress"
)
# Update shipment to delivered
await client.update_shipment_status(
tenant_id=parent_tenant_id,
shipment_id=shipment_id,
new_status="delivered",
user_id=driver_id,
metadata={"signature": "...", "photo_url": "..."}
)
```
Service Architecture:
- Base URL: Configured via DISTRIBUTION_SERVICE_URL environment variable
- Authentication: Uses BaseServiceClient with tenant_id header validation
- Error Handling: Returns None on errors, logs detailed error context
- Async: All methods are async and use httpx for HTTP communication
Related Services:
- Procurement Service: Approved internal transfer POs feed into distribution planning
- Inventory Service: Consumes shipment.delivered events for stock ownership transfer
- Tenant Service: Validates parent-child tenant relationships and location data
- Orchestrator Service: Enterprise dashboard displays delivery route status
For more details, see services/distribution/README.md
"""
import structlog
from typing import Dict, Any, List, Optional
from datetime import date
from shared.clients.base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class DistributionServiceClient(BaseServiceClient):
"""Client for communicating with the Distribution Service"""
def __init__(self, config: BaseServiceSettings, service_name: str = "unknown"):
super().__init__(service_name, config)
self.service_base_url = config.DISTRIBUTION_SERVICE_URL
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# DAILY DISTRIBUTION PLAN ENDPOINTS
# ================================================================
async def generate_daily_distribution_plan(
self,
tenant_id: str,
target_date: date,
vehicle_capacity_kg: float = 1000.0
) -> Optional[Dict[str, Any]]:
"""
Generate daily distribution plan for internal transfers
Args:
tenant_id: Tenant ID (should be parent tenant for enterprise)
target_date: Date for which to generate distribution plan
vehicle_capacity_kg: Maximum capacity per vehicle
Returns:
Distribution plan details
"""
try:
response = await self.post(
f"tenants/{tenant_id}/distribution/plans/generate",
data={
"target_date": target_date.isoformat(),
"vehicle_capacity_kg": vehicle_capacity_kg
},
tenant_id=tenant_id
)
if response:
logger.info("Generated daily distribution plan",
tenant_id=tenant_id,
target_date=target_date.isoformat())
return response
except Exception as e:
logger.error("Error generating distribution plan",
tenant_id=tenant_id,
target_date=target_date,
error=str(e))
return None
# ================================================================
# DELIVERY ROUTES ENDPOINTS
# ================================================================
async def get_delivery_routes(
self,
tenant_id: str,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
status: Optional[str] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get delivery routes with optional filtering
Args:
tenant_id: Tenant ID
date_from: Start date for filtering
date_to: End date for filtering
status: Status filter
Returns:
List of delivery route dictionaries
"""
try:
params = {}
if date_from:
params["date_from"] = date_from.isoformat()
if date_to:
params["date_to"] = date_to.isoformat()
if status:
params["status"] = status
# Use _make_request directly to construct correct URL
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
response = await self._make_request(
"GET",
f"tenants/{tenant_id}/distribution/routes",
params=params
)
if response:
# Handle different response formats
if isinstance(response, list):
# Direct list of routes
logger.info("Retrieved delivery routes",
tenant_id=tenant_id,
count=len(response))
return response
elif isinstance(response, dict):
# Response wrapped in routes key
if "routes" in response:
logger.info("Retrieved delivery routes",
tenant_id=tenant_id,
count=len(response.get("routes", [])))
return response.get("routes", [])
else:
# Return the whole dict if it's a single route
logger.info("Retrieved delivery routes",
tenant_id=tenant_id,
count=1)
return [response]
logger.info("No delivery routes found",
tenant_id=tenant_id)
return []
except Exception as e:
logger.error("Error getting delivery routes",
tenant_id=tenant_id,
error=str(e))
return []
async def get_delivery_route_detail(
self,
tenant_id: str,
route_id: str
) -> Optional[Dict[str, Any]]:
"""
Get detailed information about a specific delivery route
Args:
tenant_id: Tenant ID
route_id: Route ID
Returns:
Delivery route details
"""
try:
response = await self.get(
f"distribution/routes/{route_id}",
tenant_id=tenant_id
)
if response:
logger.info("Retrieved delivery route detail",
tenant_id=tenant_id,
route_id=route_id)
# Ensure we return the route data directly if it's wrapped in a route key
if isinstance(response, dict) and "route" in response:
return response["route"]
return response
except Exception as e:
logger.error("Error getting delivery route detail",
tenant_id=tenant_id,
route_id=route_id,
error=str(e))
return None
# ================================================================
# SHIPMENT ENDPOINTS
# ================================================================
async def get_shipments(
self,
tenant_id: str,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
status: Optional[str] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get shipments with optional filtering
Args:
tenant_id: Tenant ID
date_from: Start date for filtering
date_to: End date for filtering
status: Status filter
Returns:
List of shipment dictionaries
"""
try:
params = {}
if date_from:
params["date_from"] = date_from.isoformat()
if date_to:
params["date_to"] = date_to.isoformat()
if status:
params["status"] = status
# Use _make_request directly to construct correct URL
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
response = await self._make_request(
"GET",
f"tenants/{tenant_id}/distribution/shipments",
params=params
)
if response:
# Handle different response formats
if isinstance(response, list):
# Direct list of shipments
logger.info("Retrieved shipments",
tenant_id=tenant_id,
count=len(response))
return response
elif isinstance(response, dict):
# Response wrapped in shipments key
if "shipments" in response:
logger.info("Retrieved shipments",
tenant_id=tenant_id,
count=len(response.get("shipments", [])))
return response.get("shipments", [])
else:
# Return the whole dict if it's a single shipment
logger.info("Retrieved shipments",
tenant_id=tenant_id,
count=1)
return [response]
logger.info("No shipments found",
tenant_id=tenant_id)
return []
except Exception as e:
logger.error("Error getting shipments",
tenant_id=tenant_id,
error=str(e))
return []
async def get_shipment_detail(
self,
tenant_id: str,
shipment_id: str
) -> Optional[Dict[str, Any]]:
"""
Get detailed information about a specific shipment
Args:
tenant_id: Tenant ID
shipment_id: Shipment ID
Returns:
Shipment details
"""
try:
response = await self.get(
f"distribution/shipments/{shipment_id}",
tenant_id=tenant_id
)
if response:
logger.info("Retrieved shipment detail",
tenant_id=tenant_id,
shipment_id=shipment_id)
# Ensure we return the shipment data directly if it's wrapped in a shipment key
if isinstance(response, dict) and "shipment" in response:
return response["shipment"]
return response
except Exception as e:
logger.error("Error getting shipment detail",
tenant_id=tenant_id,
shipment_id=shipment_id,
error=str(e))
return None
async def update_shipment_status(
self,
tenant_id: str,
shipment_id: str,
new_status: str,
user_id: str,
metadata: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
"""
Update shipment status
Args:
tenant_id: Tenant ID
shipment_id: Shipment ID
new_status: New status
user_id: User ID performing update
metadata: Additional metadata for the update
Returns:
Updated shipment details
"""
try:
payload = {
"status": new_status,
"updated_by_user_id": user_id,
"metadata": metadata or {}
}
response = await self.put(
f"distribution/shipments/{shipment_id}/status",
data=payload,
tenant_id=tenant_id
)
if response:
logger.info("Updated shipment status",
tenant_id=tenant_id,
shipment_id=shipment_id,
new_status=new_status)
return response
except Exception as e:
logger.error("Error updating shipment status",
tenant_id=tenant_id,
shipment_id=shipment_id,
new_status=new_status,
error=str(e))
return None
# ================================================================
# INTERNAL DEMO ENDPOINTS
# ================================================================
# Legacy setup_enterprise_distribution_demo method removed
# Distribution now uses standard /internal/demo/clone endpoint via DataCloner
async def get_shipments_for_date(
self,
tenant_id: str,
target_date: date
) -> Optional[List[Dict[str, Any]]]:
"""
Get all shipments for a specific date
Args:
tenant_id: Tenant ID
target_date: Target date
Returns:
List of shipments for the date
"""
try:
# Use _make_request directly to construct correct URL
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
response = await self._make_request(
"GET",
f"tenants/{tenant_id}/distribution/shipments",
params={
"date_from": target_date.isoformat(),
"date_to": target_date.isoformat()
}
)
if response:
# Handle different response formats
if isinstance(response, list):
# Direct list of shipments
logger.info("Retrieved shipments for date",
tenant_id=tenant_id,
target_date=target_date.isoformat(),
shipment_count=len(response))
return response
elif isinstance(response, dict):
# Response wrapped in shipments key
if "shipments" in response:
logger.info("Retrieved shipments for date",
tenant_id=tenant_id,
target_date=target_date.isoformat(),
shipment_count=len(response.get("shipments", [])))
return response.get("shipments", [])
else:
# Return the whole dict if it's a single shipment
logger.info("Retrieved shipments for date",
tenant_id=tenant_id,
target_date=target_date.isoformat(),
shipment_count=1)
return [response]
logger.info("No shipments found for date",
tenant_id=tenant_id,
target_date=target_date.isoformat())
return []
except Exception as e:
logger.error("Error getting shipments for date",
tenant_id=tenant_id,
target_date=target_date,
error=str(e))
return []
# ================================================================
# HEALTH CHECK
# ================================================================
async def health_check(self) -> bool:
"""Check if distribution service is healthy"""
try:
# Use base health check method
response = await self.get("health")
return response is not None
except Exception as e:
logger.error("Distribution service health check failed", error=str(e))
return False
# Factory function for dependency injection
def create_distribution_client(config: BaseServiceSettings, service_name: str = "unknown") -> DistributionServiceClient:
"""Create distribution service client instance"""
return DistributionServiceClient(config, service_name)

611
shared/clients/external_client.py Executable file
View File

@@ -0,0 +1,611 @@
# shared/clients/external_client.py
"""
External Service Client
Handles all API calls to the external service (weather and traffic data)
"""
import httpx
import structlog
from typing import Dict, Any, Optional, List
from .base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class ExternalServiceClient(BaseServiceClient):
"""Client for communicating with the external service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
self.service_url = config.EXTERNAL_SERVICE_URL
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# WEATHER DATA
# ================================================================
async def get_weather_historical(
self,
tenant_id: str,
start_date: str,
end_date: str,
latitude: Optional[float] = None,
longitude: Optional[float] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get historical weather data using NEW v2.0 optimized city-based endpoint
This uses pre-loaded data from the database with Redis caching for <100ms response times
"""
# Prepare query parameters
params = {
"latitude": latitude or 40.4168, # Default Madrid coordinates
"longitude": longitude or -3.7038,
"start_date": start_date, # ISO format datetime
"end_date": end_date # ISO format datetime
}
logger.info(f"Weather request (v2.0 optimized): {params}", tenant_id=tenant_id)
# Use GET request to new optimized endpoint with short timeout (data is cached)
result = await self._make_request(
"GET",
"external/operations/historical-weather-optimized",
tenant_id=tenant_id,
params=params,
timeout=10.0 # Much shorter - data is pre-loaded and cached
)
if result:
logger.info(f"Successfully fetched {len(result)} weather records from v2.0 endpoint")
return result
else:
logger.warning("No weather data returned from v2.0 endpoint")
return []
async def get_current_weather(
self,
tenant_id: str,
latitude: Optional[float] = None,
longitude: Optional[float] = None
) -> Optional[Dict[str, Any]]:
"""
Get current weather for a location (real-time data)
Uses new v2.0 endpoint
"""
params = {
"latitude": latitude or 40.4168,
"longitude": longitude or -3.7038
}
logger.info(f"Current weather request (v2.0): {params}", tenant_id=tenant_id)
result = await self._make_request(
"GET",
"external/operations/weather/current",
tenant_id=tenant_id,
params=params,
timeout=10.0
)
if result:
logger.info("Successfully fetched current weather")
return result
else:
logger.warning("No current weather data available")
return None
async def get_weather_forecast(
self,
tenant_id: str,
days: int = 7,
latitude: Optional[float] = None,
longitude: Optional[float] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get weather forecast for location (from AEMET)
Uses new v2.0 endpoint
"""
params = {
"latitude": latitude or 40.4168,
"longitude": longitude or -3.7038,
"days": days
}
logger.info(f"Weather forecast request (v2.0): {params}", tenant_id=tenant_id)
result = await self._make_request(
"GET",
"external/operations/weather/forecast",
tenant_id=tenant_id,
params=params,
timeout=10.0
)
if result:
logger.info(f"Successfully fetched weather forecast for {days} days")
return result
else:
logger.warning("No forecast data available")
return []
# ================================================================
# TRAFFIC DATA
# ================================================================
async def get_traffic_data(
self,
tenant_id: str,
start_date: str,
end_date: str,
latitude: Optional[float] = None,
longitude: Optional[float] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get historical traffic data using NEW v2.0 optimized city-based endpoint
This uses pre-loaded data from the database with Redis caching for <100ms response times
"""
# Prepare query parameters
params = {
"latitude": latitude or 40.4168, # Default Madrid coordinates
"longitude": longitude or -3.7038,
"start_date": start_date, # ISO format datetime
"end_date": end_date # ISO format datetime
}
logger.info(f"Traffic request (v2.0 optimized): {params}", tenant_id=tenant_id)
# Use GET request to new optimized endpoint with short timeout (data is cached)
result = await self._make_request(
"GET",
"external/operations/historical-traffic-optimized",
tenant_id=tenant_id,
params=params,
timeout=10.0 # Much shorter - data is pre-loaded and cached
)
if result:
logger.info(f"Successfully fetched {len(result)} traffic records from v2.0 endpoint")
return result
else:
logger.warning("No traffic data returned from v2.0 endpoint")
return []
async def get_stored_traffic_data_for_training(
self,
tenant_id: str,
start_date: str,
end_date: str,
latitude: Optional[float] = None,
longitude: Optional[float] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get stored traffic data for model training/re-training
In v2.0, this uses the same optimized endpoint as get_traffic_data
since all data is pre-loaded and cached
"""
logger.info("Training traffic data request - delegating to optimized endpoint", tenant_id=tenant_id)
# Delegate to the same optimized endpoint
return await self.get_traffic_data(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date,
latitude=latitude,
longitude=longitude
)
async def get_current_traffic(
self,
tenant_id: str,
latitude: Optional[float] = None,
longitude: Optional[float] = None
) -> Optional[Dict[str, Any]]:
"""
Get current traffic conditions for a location (real-time data)
Uses new v2.0 endpoint
"""
params = {
"latitude": latitude or 40.4168,
"longitude": longitude or -3.7038
}
logger.info(f"Current traffic request (v2.0): {params}", tenant_id=tenant_id)
result = await self._make_request(
"GET",
"external/operations/traffic/current",
tenant_id=tenant_id,
params=params,
timeout=10.0
)
if result:
logger.info("Successfully fetched current traffic")
return result
else:
logger.warning("No current traffic data available")
return None
# ================================================================
# CALENDAR DATA (School Calendars and Hyperlocal Information)
# ================================================================
async def get_tenant_location_context(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Get tenant location context including school calendar assignment
"""
logger.info("Fetching tenant location context", tenant_id=tenant_id)
result = await self._make_request(
"GET",
"external/location-context",
tenant_id=tenant_id,
timeout=5.0
)
if result:
logger.info("Successfully fetched tenant location context", tenant_id=tenant_id)
return result
else:
logger.info("No location context found for tenant", tenant_id=tenant_id)
return None
async def create_tenant_location_context(
self,
tenant_id: str,
city_id: str,
school_calendar_id: Optional[str] = None,
neighborhood: Optional[str] = None,
local_events: Optional[List[Dict[str, Any]]] = None,
notes: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Create or update location context for a tenant.
This establishes the city association for a tenant and optionally assigns
a school calendar. Typically called during tenant registration to set up
location-based context for ML features.
Args:
tenant_id: Tenant UUID
city_id: Normalized city ID (e.g., "madrid", "barcelona")
school_calendar_id: Optional school calendar UUID to assign
neighborhood: Optional neighborhood name
local_events: Optional list of local events with impact data
notes: Optional notes about the location context
Returns:
Dict with created location context including nested calendar details,
or None if creation failed
"""
payload = {"city_id": city_id}
if school_calendar_id:
payload["school_calendar_id"] = school_calendar_id
if neighborhood:
payload["neighborhood"] = neighborhood
if local_events:
payload["local_events"] = local_events
if notes:
payload["notes"] = notes
logger.info(
"Creating tenant location context",
tenant_id=tenant_id,
city_id=city_id,
has_calendar=bool(school_calendar_id)
)
result = await self._make_request(
"POST",
"external/location-context",
tenant_id=tenant_id,
data=payload,
timeout=10.0
)
if result:
logger.info(
"Successfully created tenant location context",
tenant_id=tenant_id,
city_id=city_id
)
return result
else:
logger.warning(
"Failed to create tenant location context",
tenant_id=tenant_id,
city_id=city_id
)
return None
async def suggest_calendar_for_tenant(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Get smart calendar suggestion for a tenant based on POI data and location.
Analyzes tenant's location context, nearby schools from POI detection,
and available calendars to provide an intelligent suggestion with
confidence score and reasoning.
Args:
tenant_id: Tenant UUID
Returns:
Dict with:
- suggested_calendar_id: Suggested calendar UUID
- calendar_name: Name of suggested calendar
- confidence: Float 0.0-1.0
- confidence_percentage: Percentage format
- reasoning: List of reasoning steps
- fallback_calendars: Alternative suggestions
- should_auto_assign: Boolean recommendation
- admin_message: Formatted message for display
- school_analysis: Analysis of nearby schools
Or None if request failed
"""
logger.info("Requesting calendar suggestion", tenant_id=tenant_id)
result = await self._make_request(
"POST",
"external/location-context/suggest-calendar",
tenant_id=tenant_id,
timeout=10.0
)
if result:
confidence = result.get("confidence_percentage", 0)
suggested = result.get("calendar_name", "None")
logger.info(
"Calendar suggestion received",
tenant_id=tenant_id,
suggested_calendar=suggested,
confidence=confidence
)
return result
else:
logger.warning(
"Failed to get calendar suggestion",
tenant_id=tenant_id
)
return None
async def get_school_calendar(
self,
calendar_id: str,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Get school calendar details by ID
"""
logger.info("Fetching school calendar", calendar_id=calendar_id, tenant_id=tenant_id)
result = await self._make_request(
"GET",
f"external/operations/school-calendars/{calendar_id}",
tenant_id=tenant_id,
timeout=5.0
)
if result:
logger.info("Successfully fetched school calendar", calendar_id=calendar_id)
return result
else:
logger.warning("School calendar not found", calendar_id=calendar_id)
return None
async def check_is_school_holiday(
self,
calendar_id: str,
check_date: str,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Check if a specific date is a school holiday
Args:
calendar_id: School calendar UUID
check_date: Date to check in ISO format (YYYY-MM-DD)
tenant_id: Tenant ID for auth
Returns:
Dict with is_holiday, holiday_name, etc.
"""
params = {"check_date": check_date}
logger.debug(
"Checking school holiday status",
calendar_id=calendar_id,
date=check_date,
tenant_id=tenant_id
)
result = await self._make_request(
"GET",
f"external/operations/school-calendars/{calendar_id}/is-holiday",
tenant_id=tenant_id,
params=params,
timeout=5.0
)
return result
async def get_city_school_calendars(
self,
city_id: str,
tenant_id: str,
school_type: Optional[str] = None,
academic_year: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Get all school calendars for a city with optional filters
Args:
city_id: City ID (e.g., "madrid")
tenant_id: Tenant ID for auth
school_type: Optional filter by school type
academic_year: Optional filter by academic year
Returns:
Dict with calendars list and total count
"""
params = {}
if school_type:
params["school_type"] = school_type
if academic_year:
params["academic_year"] = academic_year
logger.info(
"Fetching school calendars for city",
city_id=city_id,
tenant_id=tenant_id,
filters=params
)
result = await self._make_request(
"GET",
f"external/operations/cities/{city_id}/school-calendars",
tenant_id=tenant_id,
params=params if params else None,
timeout=5.0
)
if result:
logger.info(
"Successfully fetched school calendars",
city_id=city_id,
total=result.get("total", 0)
)
return result
else:
logger.warning("No school calendars found for city", city_id=city_id)
return None
# ================================================================
# POI (POINT OF INTEREST) DATA
# ================================================================
async def detect_poi_for_tenant(
self,
tenant_id: str,
latitude: float,
longitude: float,
force_refresh: bool = False
) -> Optional[Dict[str, Any]]:
"""
Detect POIs for a tenant's location and generate ML features for forecasting.
With the new tenant-based architecture:
- Gateway receives at: /api/v1/tenants/{tenant_id}/external/poi-context/detect
- Gateway proxies to external service at: /api/v1/tenants/{tenant_id}/poi-context/detect
- This client calls: poi-context/detect (base client automatically constructs with tenant)
This triggers POI detection using Overpass API and calculates ML features
for demand forecasting.
Args:
tenant_id: Tenant ID
latitude: Latitude of the bakery location
longitude: Longitude of the bakery location
force_refresh: Whether to force refresh even if POI context exists
Returns:
Dict with POI detection results including:
- ml_features: Dict of POI features for ML models (e.g., poi_retail_total_count)
- poi_detection_results: Full detection results
- location: Latitude/longitude
- total_pois_detected: Count of POIs
"""
logger.info(
"Detecting POIs for tenant",
tenant_id=tenant_id,
location=(latitude, longitude),
force_refresh=force_refresh
)
params = {
"latitude": latitude,
"longitude": longitude,
"force_refresh": force_refresh
}
# Updated endpoint path to follow tenant-based pattern: external/poi-context/detect
result = await self._make_request(
"POST",
"external/poi-context/detect", # Path will become /api/v1/tenants/{tenant_id}/external/poi-context/detect by base client
tenant_id=tenant_id, # Pass tenant_id to include in headers and path construction
params=params,
timeout=60.0 # POI detection can take longer
)
if result:
poi_context = result.get("poi_context", {})
ml_features = poi_context.get("ml_features", {})
logger.info(
"POI detection completed successfully",
tenant_id=tenant_id,
total_pois=poi_context.get("total_pois_detected", 0),
ml_features_count=len(ml_features),
source=result.get("source", "unknown")
)
return result
else:
logger.warning("POI detection failed for tenant", tenant_id=tenant_id)
return None
async def get_poi_context(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Get POI context for a tenant including ML features for forecasting.
With the new tenant-based architecture:
- Gateway receives at: /api/v1/tenants/{tenant_id}/external/poi-context
- Gateway proxies to external service at: /api/v1/tenants/{tenant_id}/poi-context
- This client calls: poi-context (base client automatically constructs with tenant)
This retrieves stored POI detection results and calculated ML features
that should be included in demand forecasting predictions.
Args:
tenant_id: Tenant ID
Returns:
Dict with POI context including:
- ml_features: Dict of POI features for ML models (e.g., poi_retail_total_count)
- poi_detection_results: Full detection results
- location: Latitude/longitude
- total_pois_detected: Count of POIs
"""
logger.info("Fetching POI context for forecasting", tenant_id=tenant_id)
# Updated endpoint path to follow tenant-based pattern: external/poi-context
result = await self._make_request(
"GET",
"external/poi-context", # Path will become /api/v1/tenants/{tenant_id}/external/poi-context by base client
tenant_id=tenant_id, # Pass tenant_id to include in headers and path construction
timeout=5.0
)
if result:
logger.info(
"Successfully fetched POI context",
tenant_id=tenant_id,
total_pois=result.get("total_pois_detected", 0),
ml_features_count=len(result.get("ml_features", {}))
)
return result
else:
logger.info("No POI context found for tenant", tenant_id=tenant_id)
return None

510
shared/clients/forecast_client.py Executable file
View File

@@ -0,0 +1,510 @@
# shared/clients/forecast_client.py
"""
Forecast Service Client for Inter-Service Communication
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
from datetime import date
import structlog
from .base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class ForecastServiceClient(BaseServiceClient):
"""Client for communicating with the forecasting service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# ATOMIC: Forecast CRUD Operations
# ================================================================
async def get_forecast(self, tenant_id: str, forecast_id: str) -> Optional[Dict[str, Any]]:
"""Get forecast details by ID"""
return await self.get(f"forecasting/forecasts/{forecast_id}", tenant_id=tenant_id)
async def list_forecasts(
self,
tenant_id: str,
inventory_product_id: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
limit: int = 50,
offset: int = 0
) -> Optional[List[Dict[str, Any]]]:
"""List forecasts for a tenant with optional filters"""
params = {"limit": limit, "offset": offset}
if inventory_product_id:
params["inventory_product_id"] = inventory_product_id
if start_date:
params["start_date"] = start_date.isoformat()
if end_date:
params["end_date"] = end_date.isoformat()
return await self.get("forecasting/forecasts", tenant_id=tenant_id, params=params)
async def delete_forecast(self, tenant_id: str, forecast_id: str) -> Optional[Dict[str, Any]]:
"""Delete a forecast"""
return await self.delete(f"forecasting/forecasts/{forecast_id}", tenant_id=tenant_id)
# ================================================================
# BUSINESS: Forecasting Operations
# ================================================================
async def generate_single_forecast(
self,
tenant_id: str,
inventory_product_id: str,
forecast_date: date,
include_recommendations: bool = False
) -> Optional[Dict[str, Any]]:
"""Generate a single product forecast"""
data = {
"inventory_product_id": inventory_product_id,
"forecast_date": forecast_date.isoformat(),
"include_recommendations": include_recommendations
}
return await self.post("forecasting/operations/single", data=data, tenant_id=tenant_id)
async def generate_multi_day_forecast(
self,
tenant_id: str,
inventory_product_id: str,
forecast_date: date,
forecast_days: int = 7,
include_recommendations: bool = False
) -> Optional[Dict[str, Any]]:
"""Generate multiple daily forecasts for the specified period"""
data = {
"inventory_product_id": inventory_product_id,
"forecast_date": forecast_date.isoformat(),
"forecast_days": forecast_days,
"include_recommendations": include_recommendations
}
return await self.post("forecasting/operations/multi-day", data=data, tenant_id=tenant_id)
async def generate_batch_forecast(
self,
tenant_id: str,
inventory_product_ids: List[str],
forecast_date: date,
forecast_days: int = 1
) -> Optional[Dict[str, Any]]:
"""Generate forecasts for multiple products in batch"""
data = {
"inventory_product_ids": inventory_product_ids,
"forecast_date": forecast_date.isoformat(),
"forecast_days": forecast_days
}
return await self.post("forecasting/operations/batch", data=data, tenant_id=tenant_id)
async def generate_realtime_prediction(
self,
tenant_id: str,
inventory_product_id: str,
model_id: str,
features: Dict[str, Any],
model_path: Optional[str] = None,
confidence_level: float = 0.8
) -> Optional[Dict[str, Any]]:
"""Generate real-time prediction"""
data = {
"inventory_product_id": inventory_product_id,
"model_id": model_id,
"features": features,
"confidence_level": confidence_level
}
if model_path:
data["model_path"] = model_path
return await self.post("forecasting/operations/realtime", data=data, tenant_id=tenant_id)
async def validate_predictions(
self,
tenant_id: str,
start_date: date,
end_date: date
) -> Optional[Dict[str, Any]]:
"""Validate predictions against actual sales data"""
params = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
}
return await self.post("forecasting/operations/validate-predictions", params=params, tenant_id=tenant_id)
async def validate_forecasts(
self,
tenant_id: str,
date: date
) -> Optional[Dict[str, Any]]:
"""
Validate forecasts for a specific date against actual sales.
Calculates MAPE, RMSE, MAE and identifies products with poor accuracy.
Args:
tenant_id: Tenant UUID
date: Date to validate (validates this single day)
Returns:
Dict with overall metrics and poor accuracy products list
"""
from datetime import datetime, timezone
# Convert date to datetime with timezone for start/end of day
start_datetime = datetime.combine(date, datetime.min.time()).replace(tzinfo=timezone.utc)
end_datetime = datetime.combine(date, datetime.max.time()).replace(tzinfo=timezone.utc)
# Call the new validation endpoint
result = await self.post(
"forecasting/validation/validate-yesterday",
params={"orchestration_run_id": None},
tenant_id=tenant_id
)
if not result:
return None
# Transform the new response format to match the expected format
overall_metrics = result.get("overall_metrics", {})
# Get poor accuracy products from the result
poor_accuracy_products = result.get("poor_accuracy_products", [])
return {
"overall_mape": overall_metrics.get("mape", 0),
"overall_rmse": overall_metrics.get("rmse", 0),
"overall_mae": overall_metrics.get("mae", 0),
"overall_r2_score": overall_metrics.get("r2_score", 0),
"overall_accuracy_percentage": overall_metrics.get("accuracy_percentage", 0),
"products_validated": result.get("forecasts_with_actuals", 0),
"poor_accuracy_products": poor_accuracy_products,
"validation_run_id": result.get("validation_run_id"),
"forecasts_evaluated": result.get("forecasts_evaluated", 0),
"forecasts_with_actuals": result.get("forecasts_with_actuals", 0),
"forecasts_without_actuals": result.get("forecasts_without_actuals", 0)
}
async def get_forecast_statistics(
self,
tenant_id: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> Optional[Dict[str, Any]]:
"""Get forecast statistics"""
params = {}
if start_date:
params["start_date"] = start_date.isoformat()
if end_date:
params["end_date"] = end_date.isoformat()
return await self.get("forecasting/operations/statistics", tenant_id=tenant_id, params=params)
async def clear_prediction_cache(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Clear prediction cache"""
return await self.delete("forecasting/operations/cache", tenant_id=tenant_id)
# ================================================================
# ANALYTICS: Forecasting Analytics
# ================================================================
async def get_predictions_performance(
self,
tenant_id: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> Optional[Dict[str, Any]]:
"""Get predictions performance analytics"""
params = {}
if start_date:
params["start_date"] = start_date.isoformat()
if end_date:
params["end_date"] = end_date.isoformat()
return await self.get("forecasting/analytics/predictions-performance", tenant_id=tenant_id, params=params)
# ================================================================
# ML INSIGHTS: Dynamic Rules Generation
# ================================================================
async def trigger_rules_generation(
self,
tenant_id: str,
product_ids: Optional[List[str]] = None,
lookback_days: int = 90,
min_samples: int = 10
) -> Optional[Dict[str, Any]]:
"""
Trigger dynamic business rules learning for demand forecasting.
Args:
tenant_id: Tenant UUID
product_ids: Specific product IDs to analyze. If None, analyzes all products
lookback_days: Days of historical data to analyze (30-365)
min_samples: Minimum samples required for rule learning (5-100)
Returns:
Dict with rules generation results including insights posted
"""
data = {
"product_ids": product_ids,
"lookback_days": lookback_days,
"min_samples": min_samples
}
return await self.post("forecasting/ml/insights/generate-rules", data=data, tenant_id=tenant_id)
async def trigger_demand_insights_internal(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Trigger demand forecasting insights for a tenant (internal service use only).
This method calls the internal endpoint which is protected by x-internal-service header.
Used by demo-session service after cloning to generate AI insights from seeded data.
Args:
tenant_id: Tenant ID to trigger insights for
Returns:
Dict with trigger results or None if failed
"""
try:
result = await self._make_request(
method="POST",
endpoint=f"forecasting/internal/ml/generate-demand-insights",
tenant_id=tenant_id,
data={"tenant_id": tenant_id},
headers={"x-internal-service": "demo-session"}
)
if result:
logger.info(
"Demand insights triggered successfully via internal endpoint",
tenant_id=tenant_id,
insights_posted=result.get("insights_posted", 0)
)
else:
logger.warning(
"Demand insights internal endpoint returned no result",
tenant_id=tenant_id
)
return result
except Exception as e:
logger.error(
"Failed to trigger demand insights",
tenant_id=tenant_id,
error=str(e)
)
return None
# ================================================================
# Legacy/Compatibility Methods (deprecated)
# ================================================================
async def generate_forecasts(
self,
tenant_id: str,
forecast_days: int = 7,
inventory_product_ids: Optional[List[str]] = None
) -> Optional[Dict[str, Any]]:
"""
COMPATIBILITY: Orchestrator-friendly method to generate forecasts
This method is called by the orchestrator service and generates batch forecasts
for either specified products or all products.
Args:
tenant_id: Tenant UUID
forecast_days: Number of days to forecast (default 7)
inventory_product_ids: Optional list of product IDs. If None, forecasts all products.
Returns:
Dict with forecast results
"""
from datetime import datetime
# If no product IDs specified, let the backend handle it
if not inventory_product_ids:
# Call the batch operation endpoint to forecast all products
# The forecasting service will handle fetching all products internally
data = {
"batch_name": f"orchestrator-batch-{datetime.now().strftime('%Y%m%d')}",
"inventory_product_ids": [], # Empty list will trigger fetching all products
"forecast_days": forecast_days
}
return await self.post("forecasting/operations/batch", data=data, tenant_id=tenant_id)
# Otherwise use the standard batch forecast
return await self.generate_batch_forecast(
tenant_id=tenant_id,
inventory_product_ids=inventory_product_ids,
forecast_date=datetime.now().date(),
forecast_days=forecast_days
)
async def get_aggregated_forecast(
self,
parent_tenant_id: str,
start_date: date,
end_date: date,
product_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Get aggregated forecast for enterprise tenant and all children.
This method calls the enterprise forecasting aggregation endpoint which
combines demand forecasts across the parent tenant and all child tenants
in the network. Used for centralized production planning.
Args:
parent_tenant_id: The parent tenant (central bakery) UUID
start_date: Start date for forecast range
end_date: End date for forecast range
product_id: Optional product ID to filter forecasts
Returns:
Aggregated forecast data including:
- total_demand: Sum of all child demands
- child_contributions: Per-child demand breakdown
- forecast_date_range: Date range for the forecast
- cached: Whether data was served from Redis cache
"""
params = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
}
if product_id:
params["product_id"] = product_id
# Use _make_request directly because the base_service_client adds /tenants/{tenant_id}/ prefix
# Gateway route is: /api/v1/tenants/{tenant_id}/forecasting/enterprise/{path}
# So we need the full path without tenant_id parameter to avoid double prefixing
return await self._make_request(
"GET",
f"tenants/{parent_tenant_id}/forecasting/enterprise/aggregated",
params=params
)
async def create_forecast(
self,
tenant_id: str,
model_id: str,
start_date: str,
end_date: str,
product_ids: Optional[List[str]] = None,
include_confidence_intervals: bool = True,
**kwargs
) -> Optional[Dict[str, Any]]:
"""
DEPRECATED: Use generate_single_forecast or generate_batch_forecast instead
Legacy method for backward compatibility
"""
# Map to new batch forecast operation
if product_ids:
return await self.generate_batch_forecast(
tenant_id=tenant_id,
inventory_product_ids=product_ids,
forecast_date=date.fromisoformat(start_date),
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)

View File

@@ -0,0 +1,871 @@
# shared/clients/inventory_client.py
"""
Inventory Service Client - Inter-service communication
Handles communication with the inventory service for all other services
"""
import structlog
from typing import Dict, Any, List, Optional, Union
from uuid import UUID
from shared.clients.base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class InventoryServiceClient(BaseServiceClient):
"""Client for communicating with the inventory service via gateway"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
"""Return the base path for inventory service APIs"""
return "/api/v1"
# ================================================================
# INGREDIENT MANAGEMENT
# ================================================================
async def get_ingredient_by_id(self, ingredient_id: UUID, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get ingredient details by ID"""
try:
result = await self.get(f"inventory/ingredients/{ingredient_id}", tenant_id=tenant_id)
if result:
logger.info("Retrieved ingredient from inventory service",
ingredient_id=ingredient_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error fetching ingredient by ID",
error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
return None
async def search_ingredients(
self,
tenant_id: str,
search: Optional[str] = None,
category: Optional[str] = None,
is_active: Optional[bool] = None,
skip: int = 0,
limit: int = 100
) -> List[Dict[str, Any]]:
"""Search ingredients with filters"""
try:
params = {
"skip": skip,
"limit": limit
}
if search:
params["search"] = search
if category:
params["category"] = category
if is_active is not None:
params["is_active"] = is_active
result = await self.get("inventory/ingredients", tenant_id=tenant_id, params=params)
ingredients = result if isinstance(result, list) else []
logger.info("Searched ingredients in inventory service",
search_term=search, count=len(ingredients), tenant_id=tenant_id)
return ingredients
except Exception as e:
logger.error("Error searching ingredients",
error=str(e), search=search, tenant_id=tenant_id)
return []
async def get_all_ingredients(self, tenant_id: str, is_active: Optional[bool] = True) -> List[Dict[str, Any]]:
"""Get all ingredients for a tenant (paginated)"""
try:
params = {}
if is_active is not None:
params["is_active"] = is_active
ingredients = await self.get_paginated("inventory/ingredients", tenant_id=tenant_id, params=params)
logger.info("Retrieved all ingredients from inventory service",
count=len(ingredients), tenant_id=tenant_id)
return ingredients
except Exception as e:
logger.error("Error fetching all ingredients",
error=str(e), tenant_id=tenant_id)
return []
async def count_ingredients(self, tenant_id: str, is_active: Optional[bool] = True) -> int:
"""Get count of ingredients for a tenant"""
try:
params = {}
if is_active is not None:
params["is_active"] = is_active
result = await self.get("inventory/ingredients/count", tenant_id=tenant_id, params=params)
count = result.get("ingredient_count", 0) if isinstance(result, dict) else 0
logger.info("Retrieved ingredient count from inventory service",
count=count, tenant_id=tenant_id)
return count
except Exception as e:
logger.error("Error fetching ingredient count",
error=str(e), tenant_id=tenant_id)
return 0
async def create_ingredient(self, ingredient_data: Dict[str, Any], tenant_id: str) -> Optional[Dict[str, Any]]:
"""Create a new ingredient"""
try:
result = await self.post("inventory/ingredients", data=ingredient_data, tenant_id=tenant_id)
if result:
logger.info("Created ingredient in inventory service",
ingredient_name=ingredient_data.get('name'), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error creating ingredient",
error=str(e), ingredient_data=ingredient_data, tenant_id=tenant_id)
return None
async def update_ingredient(
self,
ingredient_id: UUID,
ingredient_data: Dict[str, Any],
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""Update an existing ingredient"""
try:
result = await self.put(f"inventory/ingredients/{ingredient_id}", data=ingredient_data, tenant_id=tenant_id)
if result:
logger.info("Updated ingredient in inventory service",
ingredient_id=ingredient_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error updating ingredient",
error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
return None
async def delete_ingredient(self, ingredient_id: UUID, tenant_id: str) -> bool:
"""Delete (deactivate) an ingredient"""
try:
result = await self.delete(f"inventory/ingredients/{ingredient_id}", tenant_id=tenant_id)
success = result is not None
if success:
logger.info("Deleted ingredient in inventory service",
ingredient_id=ingredient_id, tenant_id=tenant_id)
return success
except Exception as e:
logger.error("Error deleting ingredient",
error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
return False
async def get_ingredient_stock(
self,
ingredient_id: UUID,
tenant_id: str,
include_unavailable: bool = False
) -> List[Dict[str, Any]]:
"""Get stock entries for an ingredient"""
try:
params = {}
if include_unavailable:
params["include_unavailable"] = include_unavailable
result = await self.get(f"inventory/ingredients/{ingredient_id}/stock", tenant_id=tenant_id, params=params)
stock_entries = result if isinstance(result, list) else []
logger.info("Retrieved ingredient stock from inventory service",
ingredient_id=ingredient_id, stock_count=len(stock_entries), tenant_id=tenant_id)
return stock_entries
except Exception as e:
logger.error("Error fetching ingredient stock",
error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
return []
# ================================================================
# STOCK MANAGEMENT
# ================================================================
async def get_stock_levels(self, tenant_id: str, ingredient_ids: Optional[List[UUID]] = None) -> List[Dict[str, Any]]:
"""Get current stock levels"""
try:
params = {}
if ingredient_ids:
params["ingredient_ids"] = [str(id) for id in ingredient_ids]
result = await self.get("inventory/stock", tenant_id=tenant_id, params=params)
stock_levels = result if isinstance(result, list) else []
logger.info("Retrieved stock levels from inventory service",
count=len(stock_levels), tenant_id=tenant_id)
return stock_levels
except Exception as e:
logger.error("Error fetching stock levels",
error=str(e), tenant_id=tenant_id)
return []
async def get_low_stock_alerts(self, tenant_id: str) -> List[Dict[str, Any]]:
"""Get low stock alerts"""
try:
result = await self.get("inventory/alerts", tenant_id=tenant_id, params={"type": "low_stock"})
alerts = result if isinstance(result, list) else []
logger.info("Retrieved low stock alerts from inventory service",
count=len(alerts), tenant_id=tenant_id)
return alerts
except Exception as e:
logger.error("Error fetching low stock alerts",
error=str(e), tenant_id=tenant_id)
return []
async def consume_stock(
self,
consumption_data: Dict[str, Any],
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""Record stock consumption"""
try:
result = await self.post("inventory/operations/consume-stock", data=consumption_data, tenant_id=tenant_id)
if result:
logger.info("Recorded stock consumption",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error recording stock consumption",
error=str(e), tenant_id=tenant_id)
return None
async def receive_stock(
self,
receipt_data: Dict[str, Any],
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""Record stock receipt"""
try:
result = await self.post("inventory/operations/receive-stock", data=receipt_data, tenant_id=tenant_id)
if result:
logger.info("Recorded stock receipt",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error recording stock receipt",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# PRODUCT CLASSIFICATION (for onboarding)
# ================================================================
async def classify_product(
self,
product_name: str,
sales_volume: Optional[float],
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""Classify a single product for inventory creation"""
try:
classification_data = {
"product_name": product_name,
"sales_volume": sales_volume
}
result = await self.post("inventory/operations/classify-product", data=classification_data, tenant_id=tenant_id)
if result:
logger.info("Classified product",
product=product_name,
classification=result.get('product_type'),
confidence=result.get('confidence_score'),
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error classifying product",
error=str(e), product=product_name, tenant_id=tenant_id)
return None
async def classify_products_batch(
self,
products: List[Dict[str, Any]],
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""Classify multiple products for onboarding automation"""
try:
classification_data = {
"products": products
}
result = await self.post("inventory/operations/classify-products-batch", data=classification_data, tenant_id=tenant_id)
if result:
suggestions = result.get('suggestions', [])
business_model = result.get('business_model_analysis', {}).get('model', 'unknown')
logger.info("Batch classification complete",
total_products=len(suggestions),
business_model=business_model,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error in batch classification",
error=str(e), products_count=len(products), tenant_id=tenant_id)
return None
async def resolve_or_create_products_batch(
self,
products: List[Dict[str, Any]],
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""Resolve or create multiple products in a single batch operation"""
try:
batch_data = {
"products": products
}
result = await self.post("inventory/operations/resolve-or-create-products-batch",
data=batch_data, tenant_id=tenant_id)
if result:
created = result.get('created_count', 0)
resolved = result.get('resolved_count', 0)
failed = result.get('failed_count', 0)
logger.info("Batch product resolution complete",
created=created,
resolved=resolved,
failed=failed,
total=len(products),
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error in batch product resolution",
error=str(e), products_count=len(products), tenant_id=tenant_id)
return None
# ================================================================
# DASHBOARD AND ANALYTICS
# ================================================================
async def get_inventory_dashboard(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get inventory dashboard data"""
try:
result = await self.get("inventory/dashboard/overview", tenant_id=tenant_id)
if result:
logger.info("Retrieved inventory dashboard data", tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error fetching inventory dashboard",
error=str(e), tenant_id=tenant_id)
return None
async def get_inventory_summary(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get inventory summary statistics"""
try:
result = await self.get("inventory/dashboard/summary", tenant_id=tenant_id)
if result:
logger.info("Retrieved inventory summary", tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error fetching inventory summary",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# PRODUCT TRANSFORMATION
# ================================================================
async def create_transformation(
self,
transformation_data: Dict[str, Any],
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""Create a product transformation (e.g., par-baked to fully baked)"""
try:
result = await self.post("inventory/transformations", data=transformation_data, tenant_id=tenant_id)
if result:
logger.info("Created product transformation",
transformation_reference=result.get('transformation_reference'),
source_stage=transformation_data.get('source_stage'),
target_stage=transformation_data.get('target_stage'),
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error creating transformation",
error=str(e), transformation_data=transformation_data, tenant_id=tenant_id)
return None
async def create_par_bake_transformation(
self,
source_ingredient_id: Union[str, UUID],
target_ingredient_id: Union[str, UUID],
quantity: float,
tenant_id: str,
target_batch_number: Optional[str] = None,
expiration_hours: int = 24,
notes: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Convenience method for par-baked to fresh transformation"""
try:
params = {
"source_ingredient_id": str(source_ingredient_id),
"target_ingredient_id": str(target_ingredient_id),
"quantity": quantity,
"expiration_hours": expiration_hours
}
if target_batch_number:
params["target_batch_number"] = target_batch_number
if notes:
params["notes"] = notes
result = await self.post("inventory/transformations/par-bake-to-fresh", params=params, tenant_id=tenant_id)
if result:
logger.info("Created par-bake transformation",
transformation_id=result.get('transformation_id'),
quantity=quantity, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error creating par-bake transformation",
error=str(e), source_ingredient_id=source_ingredient_id,
target_ingredient_id=target_ingredient_id, tenant_id=tenant_id)
return None
async def get_transformations(
self,
tenant_id: str,
ingredient_id: Optional[Union[str, UUID]] = None,
source_stage: Optional[str] = None,
target_stage: Optional[str] = None,
days_back: Optional[int] = None,
skip: int = 0,
limit: int = 100
) -> List[Dict[str, Any]]:
"""Get product transformations with filtering"""
try:
params = {
"skip": skip,
"limit": limit
}
if ingredient_id:
params["ingredient_id"] = str(ingredient_id)
if source_stage:
params["source_stage"] = source_stage
if target_stage:
params["target_stage"] = target_stage
if days_back:
params["days_back"] = days_back
result = await self.get("inventory/transformations", tenant_id=tenant_id, params=params)
transformations = result if isinstance(result, list) else []
logger.info("Retrieved transformations from inventory service",
count=len(transformations), tenant_id=tenant_id)
return transformations
except Exception as e:
logger.error("Error fetching transformations",
error=str(e), tenant_id=tenant_id)
return []
async def get_transformation_by_id(
self,
transformation_id: Union[str, UUID],
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""Get specific transformation by ID"""
try:
result = await self.get(f"inventory/transformations/{transformation_id}", tenant_id=tenant_id)
if result:
logger.info("Retrieved transformation by ID",
transformation_id=transformation_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error fetching transformation by ID",
error=str(e), transformation_id=transformation_id, tenant_id=tenant_id)
return None
async def get_transformation_summary(
self,
tenant_id: str,
days_back: int = 30
) -> Optional[Dict[str, Any]]:
"""Get transformation summary for dashboard"""
try:
params = {"days_back": days_back}
result = await self.get("inventory/dashboard/transformations-summary", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved transformation summary",
days_back=days_back, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error fetching transformation summary",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# BATCH OPERATIONS (NEW - for Orchestrator optimization)
# ================================================================
async def get_ingredients_batch(
self,
tenant_id: str,
ingredient_ids: List[UUID]
) -> Dict[str, Any]:
"""
Fetch multiple ingredients in a single request.
This method reduces N API calls to 1, significantly improving
performance when fetching data for multiple ingredients.
Args:
tenant_id: Tenant ID
ingredient_ids: List of ingredient IDs to fetch
Returns:
Dict with 'ingredients', 'found_count', and 'missing_ids'
"""
try:
if not ingredient_ids:
return {
'ingredients': [],
'found_count': 0,
'missing_ids': []
}
# Convert UUIDs to strings for JSON serialization
ids_str = [str(id) for id in ingredient_ids]
result = await self.post(
"inventory/operations/ingredients/batch",
data={"ingredient_ids": ids_str},
tenant_id=tenant_id
)
if result:
logger.info(
"Retrieved ingredients in batch",
requested=len(ingredient_ids),
found=result.get('found_count', 0),
tenant_id=tenant_id
)
return result or {'ingredients': [], 'found_count': 0, 'missing_ids': ids_str}
except Exception as e:
logger.error(
"Error fetching ingredients in batch",
error=str(e),
count=len(ingredient_ids),
tenant_id=tenant_id
)
return {'ingredients': [], 'found_count': 0, 'missing_ids': [str(id) for id in ingredient_ids]}
async def get_stock_levels_batch(
self,
tenant_id: str,
ingredient_ids: List[UUID]
) -> Dict[str, float]:
"""
Fetch stock levels for multiple ingredients in a single request.
Args:
tenant_id: Tenant ID
ingredient_ids: List of ingredient IDs
Returns:
Dict mapping ingredient_id (str) to stock level (float)
"""
try:
if not ingredient_ids:
return {}
# Convert UUIDs to strings for JSON serialization
ids_str = [str(id) for id in ingredient_ids]
result = await self.post(
"inventory/operations/stock-levels/batch",
data={"ingredient_ids": ids_str},
tenant_id=tenant_id
)
stock_levels = result.get('stock_levels', {}) if result else {}
logger.info(
"Retrieved stock levels in batch",
requested=len(ingredient_ids),
found=len(stock_levels),
tenant_id=tenant_id
)
return stock_levels
except Exception as e:
logger.error(
"Error fetching stock levels in batch",
error=str(e),
count=len(ingredient_ids),
tenant_id=tenant_id
)
return {}
# ================================================================
# ML INSIGHTS: Safety Stock Optimization
# ================================================================
async def trigger_safety_stock_optimization(
self,
tenant_id: str,
product_ids: Optional[List[str]] = None,
lookback_days: int = 90,
min_history_days: int = 30
) -> Optional[Dict[str, Any]]:
"""
Trigger safety stock optimization for inventory products.
Args:
tenant_id: Tenant UUID
product_ids: Specific product IDs to optimize. If None, optimizes all products
lookback_days: Days of historical demand to analyze (30-365)
min_history_days: Minimum days of history required (7-180)
Returns:
Dict with optimization results including insights posted
"""
try:
data = {
"product_ids": product_ids,
"lookback_days": lookback_days,
"min_history_days": min_history_days
}
result = await self.post("inventory/ml/insights/optimize-safety-stock", data=data, tenant_id=tenant_id)
if result:
logger.info("Triggered safety stock optimization",
products_optimized=result.get('products_optimized', 0),
insights_posted=result.get('total_insights_posted', 0),
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error triggering safety stock optimization",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# DASHBOARD METHODS
# ================================================================
async def get_inventory_summary_batch(
self,
tenant_ids: List[str]
) -> Dict[str, Any]:
"""
Get inventory summaries for multiple tenants in a single request.
Phase 2 optimization: Eliminates N+1 query patterns for enterprise dashboards.
Args:
tenant_ids: List of tenant IDs to fetch
Returns:
Dict mapping tenant_id -> inventory summary
"""
try:
if not tenant_ids:
return {}
if len(tenant_ids) > 100:
logger.warning("Batch request exceeds max tenant limit", requested=len(tenant_ids))
tenant_ids = tenant_ids[:100]
result = await self.post(
"inventory/batch/inventory-summary",
data={"tenant_ids": tenant_ids},
tenant_id=tenant_ids[0] # Use first tenant for auth context
)
summaries = result if isinstance(result, dict) else {}
logger.info(
"Batch retrieved inventory summaries",
requested=len(tenant_ids),
found=len(summaries)
)
return summaries
except Exception as e:
logger.error(
"Error batch fetching inventory summaries",
error=str(e),
tenant_count=len(tenant_ids)
)
return {}
async def get_stock_status(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Get inventory stock status for dashboard insights
Args:
tenant_id: Tenant ID
Returns:
Dict with stock counts and status metrics
"""
try:
return await self.get(
"/inventory/dashboard/stock-status",
tenant_id=tenant_id
)
except Exception as e:
logger.error("Error fetching stock status", error=str(e), tenant_id=tenant_id)
return None
async def get_sustainability_widget(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Get sustainability metrics for dashboard
Args:
tenant_id: Tenant ID
Returns:
Dict with sustainability metrics (waste, CO2, etc.)
"""
try:
return await self.get(
"/sustainability/widget",
tenant_id=tenant_id
)
except Exception as e:
logger.error("Error fetching sustainability widget", error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if inventory service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Inventory service health check failed", error=str(e))
return False
async def trigger_inventory_alerts_internal(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Trigger inventory alerts for a tenant (internal service use only).
This method calls the internal endpoint which is protected by x-internal-service header.
The endpoint should trigger alerts specifically for the given tenant.
Args:
tenant_id: Tenant ID to trigger alerts for
Returns:
Dict with trigger results or None if failed
"""
try:
# Call internal endpoint via gateway using tenant-scoped URL pattern
# Endpoint: /api/v1/tenants/{tenant_id}/inventory/internal/alerts/trigger
result = await self._make_request(
method="POST",
endpoint="inventory/internal/alerts/trigger",
tenant_id=tenant_id,
data={},
headers={"x-internal-service": "demo-session"}
)
if result:
logger.info(
"Inventory alerts triggered successfully via internal endpoint",
tenant_id=tenant_id,
alerts_generated=result.get("alerts_generated", 0)
)
else:
logger.warning(
"Inventory alerts internal endpoint returned no result",
tenant_id=tenant_id
)
return result
except Exception as e:
logger.error(
"Error triggering inventory alerts via internal endpoint",
tenant_id=tenant_id,
error=str(e)
)
return None
# ================================================================
# INTERNAL AI INSIGHTS METHODS
# ================================================================
async def trigger_safety_stock_insights_internal(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Trigger safety stock optimization insights for a tenant (internal service use only).
This method calls the internal endpoint which is protected by x-internal-service header.
Args:
tenant_id: Tenant ID to trigger insights for
Returns:
Dict with trigger results or None if failed
"""
try:
result = await self._make_request(
method="POST",
endpoint="inventory/internal/ml/generate-safety-stock-insights",
tenant_id=tenant_id,
data={"tenant_id": tenant_id},
headers={"x-internal-service": "demo-session"}
)
if result:
logger.info(
"Safety stock insights triggered successfully via internal endpoint",
tenant_id=tenant_id,
insights_posted=result.get("insights_posted", 0)
)
else:
logger.warning(
"Safety stock insights internal endpoint returned no result",
tenant_id=tenant_id
)
return result
except Exception as e:
logger.error(
"Error triggering safety stock insights via internal endpoint",
tenant_id=tenant_id,
error=str(e)
)
return None
# Factory function for dependency injection
def create_inventory_client(config: BaseServiceSettings, service_name: str = "unknown") -> InventoryServiceClient:
"""Create inventory service client instance"""
return InventoryServiceClient(config, calling_service_name=service_name)
# Convenience function for quick access (requires config to be passed)
async def get_inventory_client(config: BaseServiceSettings) -> InventoryServiceClient:
"""Get inventory service client instance"""
return create_inventory_client(config)

View File

@@ -0,0 +1,418 @@
"""
MinIO Client Library
Shared client for MinIO object storage operations with TLS support
"""
import os
import io
import ssl
import time
import urllib3
from typing import Optional, Dict, Any, Union
from pathlib import Path
from functools import wraps
from minio import Minio
from minio.error import S3Error
import structlog
# Configure logger
logger = structlog.get_logger()
def with_retry(max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 30.0):
"""Decorator for retrying operations with exponential backoff
Args:
max_retries: Maximum number of retry attempts
base_delay: Initial delay between retries in seconds
max_delay: Maximum delay between retries in seconds
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except (S3Error, urllib3.exceptions.HTTPError, ConnectionError, TimeoutError) as e:
last_exception = e
if attempt < max_retries:
# Exponential backoff with jitter
delay = min(base_delay * (2 ** attempt), max_delay)
logger.warning(
f"MinIO operation failed, retrying in {delay:.1f}s",
attempt=attempt + 1,
max_retries=max_retries,
error=str(e)
)
time.sleep(delay)
else:
logger.error(
"MinIO operation failed after all retries",
attempts=max_retries + 1,
error=str(e)
)
raise last_exception
return wrapper
return decorator
class MinIOClient:
"""Client for MinIO object storage operations with TLS support"""
def __init__(self):
"""Initialize MinIO client with configuration"""
self._client = None
self._initialize_client()
def _initialize_client(self) -> None:
"""Initialize MinIO client from environment variables with SSL/TLS support"""
try:
# Get configuration from environment
endpoint = os.getenv("MINIO_ENDPOINT", "minio.bakery-ia.svc.cluster.local:9000")
access_key = os.getenv("MINIO_ACCESS_KEY", os.getenv("MINIO_ROOT_USER", "admin"))
secret_key = os.getenv("MINIO_SECRET_KEY", os.getenv("MINIO_ROOT_PASSWORD", "secure-password"))
use_ssl = os.getenv("MINIO_USE_SSL", "true").lower() == "true"
# TLS certificate paths (optional - for cert verification)
ca_cert_path = os.getenv("MINIO_CA_CERT_PATH", "/etc/ssl/certs/minio-ca.crt")
# SSL verification is disabled by default for internal cluster with self-signed certs
# Set MINIO_VERIFY_SSL=true and provide CA cert path for production with proper certs
verify_ssl = os.getenv("MINIO_VERIFY_SSL", "false").lower() == "true"
# Try to get settings from service configuration if available
try:
from app.core.config import settings
if hasattr(settings, 'MINIO_ENDPOINT'):
endpoint = settings.MINIO_ENDPOINT
access_key = settings.MINIO_ACCESS_KEY
secret_key = settings.MINIO_SECRET_KEY
use_ssl = settings.MINIO_USE_SSL
except ImportError:
# Fallback to environment variables (for shared client usage)
pass
# Configure HTTP client with TLS settings
http_client = None
if use_ssl:
# Create custom HTTP client for TLS
if verify_ssl and os.path.exists(ca_cert_path):
# Verify certificates against CA
http_client = urllib3.PoolManager(
timeout=urllib3.Timeout(connect=10.0, read=60.0),
maxsize=10,
cert_reqs='CERT_REQUIRED',
ca_certs=ca_cert_path,
retries=urllib3.Retry(
total=5,
backoff_factor=0.2,
status_forcelist=[500, 502, 503, 504]
)
)
logger.info("MinIO TLS with certificate verification enabled",
ca_cert_path=ca_cert_path)
else:
# TLS without certificate verification (for self-signed certs in internal cluster)
# Still encrypted, just skips cert validation
http_client = urllib3.PoolManager(
timeout=urllib3.Timeout(connect=10.0, read=60.0),
maxsize=10,
cert_reqs='CERT_NONE',
retries=urllib3.Retry(
total=5,
backoff_factor=0.2,
status_forcelist=[500, 502, 503, 504]
)
)
# Suppress insecure request warnings for internal cluster
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger.info("MinIO TLS enabled without certificate verification (internal cluster)")
# Initialize client with SSL/TLS
self._client = Minio(
endpoint,
access_key=access_key,
secret_key=secret_key,
secure=use_ssl,
http_client=http_client
)
logger.info("MinIO client initialized successfully",
endpoint=endpoint,
use_ssl=use_ssl,
verify_ssl=verify_ssl if use_ssl else False)
except Exception as e:
logger.error("Failed to initialize MinIO client", error=str(e))
raise
def reconnect(self) -> bool:
"""Reconnect to MinIO server
Useful when connection is lost or credentials have changed.
Returns:
True if reconnection succeeded, False otherwise
"""
try:
logger.info("Attempting to reconnect to MinIO...")
self._initialize_client()
return True
except Exception as e:
logger.error("Failed to reconnect to MinIO", error=str(e))
return False
@with_retry(max_retries=3, base_delay=1.0)
def bucket_exists(self, bucket_name: str) -> bool:
"""Check if bucket exists - handles limited permissions gracefully"""
try:
# First try the standard method
return self._client.bucket_exists(bucket_name)
except S3Error as e:
# If we get AccessDenied, try alternative method for limited-permission users
if e.code == "AccessDenied":
logger.debug("Access denied for bucket_exists, trying alternative method",
bucket_name=bucket_name)
try:
# Try to list objects - this works with ListBucket permission
# If bucket doesn't exist, this will raise NoSuchBucket error
# If bucket exists but user has no permission, this will raise AccessDenied
objects = list(self._client.list_objects(bucket_name, recursive=False))
logger.debug("Bucket exists (verified via list_objects)", bucket_name=bucket_name)
return True
except S3Error as list_error:
if list_error.code == "NoSuchBucket":
logger.debug("Bucket does not exist", bucket_name=bucket_name)
return False
else:
logger.error("Failed to check bucket existence (alternative method)",
bucket_name=bucket_name,
error=str(list_error))
return False
else:
logger.error("Failed to check bucket existence",
bucket_name=bucket_name,
error=str(e))
return False
def create_bucket(self, bucket_name: str, region: str = "us-east-1") -> bool:
"""Create a new bucket if it doesn't exist"""
try:
if not self.bucket_exists(bucket_name):
self._client.make_bucket(bucket_name, region)
logger.info("Created MinIO bucket", bucket_name=bucket_name)
return True
return False
except S3Error as e:
logger.error("Failed to create bucket",
bucket_name=bucket_name,
error=str(e))
return False
@with_retry(max_retries=3, base_delay=1.0)
def put_object(
self,
bucket_name: str,
object_name: str,
data: Union[bytes, io.BytesIO, str, Path],
length: Optional[int] = None,
content_type: str = "application/octet-stream",
metadata: Optional[Dict[str, str]] = None
) -> bool:
"""Upload an object to MinIO
Args:
bucket_name: Target bucket name
object_name: Object key/path in the bucket
data: Data to upload (bytes, BytesIO, string, or Path)
length: Optional data length (calculated automatically if not provided)
content_type: MIME type of the object
metadata: Optional metadata dictionary
Returns:
True if upload succeeded, False otherwise
"""
try:
# Ensure bucket exists
self.create_bucket(bucket_name)
# Convert data to bytes if needed
if isinstance(data, str):
data = data.encode('utf-8')
elif isinstance(data, Path):
with open(data, 'rb') as f:
data = f.read()
elif isinstance(data, io.BytesIO):
data = data.getvalue()
# Calculate length if not provided
data_length = length if length is not None else len(data)
# MinIO SDK requires BytesIO stream and explicit length
data_stream = io.BytesIO(data)
# Upload object with proper stream and length
self._client.put_object(
bucket_name,
object_name,
data_stream,
length=data_length,
content_type=content_type,
metadata=metadata
)
logger.info("Uploaded object to MinIO",
bucket_name=bucket_name,
object_name=object_name,
size=data_length)
return True
except S3Error as e:
logger.error("Failed to upload object",
bucket_name=bucket_name,
object_name=object_name,
error=str(e))
return False
@with_retry(max_retries=3, base_delay=1.0)
def get_object(self, bucket_name: str, object_name: str) -> Optional[bytes]:
"""Download an object from MinIO"""
try:
# Get object data
response = self._client.get_object(bucket_name, object_name)
data = response.read()
logger.info("Downloaded object from MinIO",
bucket_name=bucket_name,
object_name=object_name,
size=len(data))
return data
except S3Error as e:
logger.error("Failed to download object",
bucket_name=bucket_name,
object_name=object_name,
error=str(e))
return None
def object_exists(self, bucket_name: str, object_name: str) -> bool:
"""Check if object exists"""
try:
self._client.stat_object(bucket_name, object_name)
return True
except S3Error:
return False
def list_objects(self, bucket_name: str, prefix: str = "") -> list:
"""List objects in bucket with optional prefix"""
try:
objects = self._client.list_objects(bucket_name, prefix=prefix, recursive=True)
return [obj.object_name for obj in objects]
except S3Error as e:
logger.error("Failed to list objects",
bucket_name=bucket_name,
prefix=prefix,
error=str(e))
return []
def delete_object(self, bucket_name: str, object_name: str) -> bool:
"""Delete an object from MinIO"""
try:
self._client.remove_object(bucket_name, object_name)
logger.info("Deleted object from MinIO",
bucket_name=bucket_name,
object_name=object_name)
return True
except S3Error as e:
logger.error("Failed to delete object",
bucket_name=bucket_name,
object_name=object_name,
error=str(e))
return False
def get_presigned_url(
self,
bucket_name: str,
object_name: str,
expires: int = 3600
) -> Optional[str]:
"""Generate presigned URL for object access"""
try:
url = self._client.presigned_get_object(
bucket_name,
object_name,
expires=expires
)
return url
except S3Error as e:
logger.error("Failed to generate presigned URL",
bucket_name=bucket_name,
object_name=object_name,
error=str(e))
return None
def copy_object(
self,
source_bucket: str,
source_object: str,
dest_bucket: str,
dest_object: str
) -> bool:
"""Copy object within MinIO"""
try:
# Ensure destination bucket exists
self.create_bucket(dest_bucket)
# Copy object
self._client.copy_object(dest_bucket, dest_object,
f"{source_bucket}/{source_object}")
logger.info("Copied object in MinIO",
source_bucket=source_bucket,
source_object=source_object,
dest_bucket=dest_bucket,
dest_object=dest_object)
return True
except S3Error as e:
logger.error("Failed to copy object",
source_bucket=source_bucket,
source_object=source_object,
dest_bucket=dest_bucket,
dest_object=dest_object,
error=str(e))
return False
def get_object_metadata(self, bucket_name: str, object_name: str) -> Optional[Dict[str, Any]]:
"""Get object metadata"""
try:
stat = self._client.stat_object(bucket_name, object_name)
return {
"size": stat.size,
"last_modified": stat.last_modified,
"content_type": stat.content_type,
"metadata": stat.metadata or {}
}
except S3Error as e:
logger.error("Failed to get object metadata",
bucket_name=bucket_name,
object_name=object_name,
error=str(e))
return None
def health_check(self) -> bool:
"""Check MinIO service health"""
try:
# Simple bucket list to check connectivity
self._client.list_buckets()
return True
except Exception as e:
logger.error("MinIO health check failed", error=str(e))
return False
# Singleton instance for convenience
minio_client = MinIOClient()

View File

@@ -0,0 +1,205 @@
"""
Nominatim Client for geocoding and address search
"""
import structlog
import httpx
from typing import Optional, List, Dict, Any
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class NominatimClient:
"""
Client for Nominatim geocoding service.
Provides address search and geocoding capabilities for the bakery onboarding flow.
"""
def __init__(self, config: BaseServiceSettings):
self.config = config
self.nominatim_url = getattr(
config,
"NOMINATIM_SERVICE_URL",
"http://nominatim-service:8080"
)
self.timeout = 30
async def search_address(
self,
query: str,
country_codes: str = "es",
limit: int = 5,
addressdetails: bool = True
) -> List[Dict[str, Any]]:
"""
Search for addresses matching a query.
Args:
query: Address search query (e.g., "Calle Mayor 1, Madrid")
country_codes: Limit search to country codes (default: "es" for Spain)
limit: Maximum number of results (default: 5)
addressdetails: Include detailed address breakdown (default: True)
Returns:
List of geocoded results with lat, lon, and address details
Example:
results = await nominatim.search_address("Calle Mayor 1, Madrid")
if results:
lat = results[0]["lat"]
lon = results[0]["lon"]
display_name = results[0]["display_name"]
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.nominatim_url}/search",
params={
"q": query,
"format": "json",
"countrycodes": country_codes,
"addressdetails": 1 if addressdetails else 0,
"limit": limit
}
)
if response.status_code == 200:
results = response.json()
logger.info(
"Address search completed",
query=query,
results_count=len(results)
)
return results
else:
logger.error(
"Nominatim search failed",
query=query,
status_code=response.status_code,
response=response.text
)
return []
except httpx.TimeoutException:
logger.error("Nominatim search timeout", query=query)
return []
except Exception as e:
logger.error("Nominatim search error", query=query, error=str(e))
return []
async def geocode_address(
self,
street: str,
city: str,
postal_code: Optional[str] = None,
country: str = "Spain"
) -> Optional[Dict[str, Any]]:
"""
Geocode a structured address to coordinates.
Args:
street: Street name and number
city: City name
postal_code: Optional postal code
country: Country name (default: "Spain")
Returns:
Dict with lat, lon, and display_name, or None if not found
Example:
location = await nominatim.geocode_address(
street="Calle Mayor 1",
city="Madrid",
postal_code="28013"
)
if location:
lat, lon = location["lat"], location["lon"]
"""
# Build structured query
query_parts = [street, city]
if postal_code:
query_parts.append(postal_code)
query_parts.append(country)
query = ", ".join(query_parts)
results = await self.search_address(query, limit=1)
if results:
return results[0]
return None
async def reverse_geocode(
self,
latitude: float,
longitude: float
) -> Optional[Dict[str, Any]]:
"""
Reverse geocode coordinates to an address.
Args:
latitude: Latitude coordinate
longitude: Longitude coordinate
Returns:
Dict with address details, or None if not found
Example:
address = await nominatim.reverse_geocode(40.4168, -3.7038)
if address:
city = address["address"]["city"]
street = address["address"]["road"]
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.nominatim_url}/reverse",
params={
"lat": latitude,
"lon": longitude,
"format": "json",
"addressdetails": 1
}
)
if response.status_code == 200:
result = response.json()
logger.info(
"Reverse geocoding completed",
lat=latitude,
lon=longitude
)
return result
else:
logger.error(
"Nominatim reverse geocoding failed",
lat=latitude,
lon=longitude,
status_code=response.status_code
)
return None
except Exception as e:
logger.error(
"Reverse geocoding error",
lat=latitude,
lon=longitude,
error=str(e)
)
return None
async def health_check(self) -> bool:
"""
Check if Nominatim service is healthy.
Returns:
True if service is responding, False otherwise
"""
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(f"{self.nominatim_url}/status")
return response.status_code == 200
except Exception as e:
logger.warning("Nominatim health check failed", error=str(e))
return False

View File

@@ -0,0 +1,186 @@
# shared/clients/notification_client.py
"""
Notification Service Client for Inter-Service Communication
Provides access to notification and email sending from other services
"""
import structlog
from typing import Dict, Any, Optional
from uuid import UUID
from shared.clients.base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class NotificationServiceClient(BaseServiceClient):
"""Client for communicating with the Notification Service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# NOTIFICATION ENDPOINTS
# ================================================================
async def send_notification(
self,
tenant_id: str,
notification_type: str,
message: str,
recipient_email: Optional[str] = None,
subject: Optional[str] = None,
html_content: Optional[str] = None,
priority: str = "normal",
metadata: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
"""
Send a notification
Args:
tenant_id: Tenant ID (UUID as string)
notification_type: Type of notification (email, sms, push, in_app)
message: Notification message
recipient_email: Recipient email address (for email notifications)
subject: Email subject (for email notifications)
html_content: HTML content for email (optional)
priority: Priority level (low, normal, high, urgent)
metadata: Additional metadata
Returns:
Dictionary with notification details
"""
try:
notification_data = {
"type": notification_type,
"message": message,
"priority": priority,
"recipient_email": recipient_email,
"subject": subject,
"html_content": html_content,
"metadata": metadata or {}
}
result = await self.post("notifications/send", data=notification_data, tenant_id=tenant_id)
if result:
logger.info("Notification sent successfully",
tenant_id=tenant_id,
notification_type=notification_type)
return result
except Exception as e:
logger.error("Error sending notification",
error=str(e),
tenant_id=tenant_id,
notification_type=notification_type)
return None
async def send_email(
self,
tenant_id: str,
to_email: str,
subject: str,
message: str,
html_content: Optional[str] = None,
priority: str = "normal"
) -> Optional[Dict[str, Any]]:
"""
Send an email notification (convenience method)
Args:
tenant_id: Tenant ID (UUID as string)
to_email: Recipient email address
subject: Email subject
message: Email message (plain text)
html_content: HTML version of email (optional)
priority: Priority level (low, normal, high, urgent)
Returns:
Dictionary with notification details
"""
return await self.send_notification(
tenant_id=tenant_id,
notification_type="email",
message=message,
recipient_email=to_email,
subject=subject,
html_content=html_content,
priority=priority
)
async def send_workflow_summary(
self,
tenant_id: str,
notification_data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""
Send workflow summary notification
Args:
tenant_id: Tenant ID
notification_data: Summary data to include in notification
Returns:
Dictionary with notification result
"""
try:
# Prepare workflow summary notification
subject = f"Daily Workflow Summary - {notification_data.get('orchestration_run_id', 'N/A')}"
message_parts = [
f"Daily workflow completed for tenant {tenant_id}",
f"Orchestration Run ID: {notification_data.get('orchestration_run_id', 'N/A')}",
f"Forecasts created: {notification_data.get('forecasts_created', 0)}",
f"Production batches created: {notification_data.get('batches_created', 0)}",
f"Procurement requirements created: {notification_data.get('requirements_created', 0)}",
f"Purchase orders created: {notification_data.get('pos_created', 0)}"
]
message = "\n".join(message_parts)
notification_payload = {
"type": "email",
"message": message,
"priority": "normal",
"subject": subject,
"metadata": {
"orchestration_run_id": notification_data.get('orchestration_run_id'),
"forecast_id": notification_data.get('forecast_id'),
"production_schedule_id": notification_data.get('production_schedule_id'),
"procurement_plan_id": notification_data.get('procurement_plan_id'),
"summary_type": "workflow_completion"
}
}
result = await self.post("notifications/send", data=notification_payload, tenant_id=tenant_id)
if result:
logger.info("Workflow summary notification sent successfully",
tenant_id=tenant_id,
orchestration_run_id=notification_data.get('orchestration_run_id'))
return result
except Exception as e:
logger.error("Error sending workflow summary notification",
error=str(e),
tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if notification service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Notification service health check failed", error=str(e))
return False
# Factory function for dependency injection
def create_notification_client(config: BaseServiceSettings) -> NotificationServiceClient:
"""Create notification service client instance"""
return NotificationServiceClient(config)

251
shared/clients/orders_client.py Executable file
View File

@@ -0,0 +1,251 @@
# shared/clients/orders_client.py
"""
Orders Service Client for Inter-Service Communication
Provides access to orders and procurement planning from other services
"""
import structlog
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
logger = structlog.get_logger()
class OrdersServiceClient(BaseServiceClient):
"""Client for communicating with the Orders Service"""
def __init__(self, config: BaseServiceSettings):
super().__init__("orders", config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# PROCUREMENT PLANNING
# ================================================================
async def get_demand_requirements(self, tenant_id: str, date: str) -> Optional[Dict[str, Any]]:
"""Get demand requirements for production planning"""
try:
params = {"date": date}
result = await self.get("orders/demand-requirements", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved demand requirements from orders service",
date=date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting demand requirements",
error=str(e), date=date, tenant_id=tenant_id)
return None
async def get_procurement_requirements(self, tenant_id: str, horizon: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get procurement requirements for purchasing planning"""
try:
params = {}
if horizon:
params["horizon"] = horizon
result = await self.get("orders/procurement-requirements", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved procurement requirements from orders service",
horizon=horizon, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting procurement requirements",
error=str(e), tenant_id=tenant_id)
return None
async def get_weekly_ingredient_needs(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get weekly ingredient ordering needs for dashboard"""
try:
result = await self.get("orders/dashboard/weekly-ingredient-needs", tenant_id=tenant_id)
if result:
logger.info("Retrieved weekly ingredient needs from orders service",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting weekly ingredient needs",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# CUSTOMER ORDERS
# ================================================================
async def get_customer_orders(self, tenant_id: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Get customer orders with optional filtering"""
try:
result = await self.get("orders/list", tenant_id=tenant_id, params=params)
if result:
orders_count = len(result.get('orders', [])) if isinstance(result, dict) else len(result) if isinstance(result, list) else 0
logger.info("Retrieved customer orders from orders service",
orders_count=orders_count, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting customer orders",
error=str(e), tenant_id=tenant_id)
return None
async def create_customer_order(self, tenant_id: str, order_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Create a new customer order"""
try:
result = await self.post("orders/list", data=order_data, tenant_id=tenant_id)
if result:
logger.info("Created customer order",
order_id=result.get('id'), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error creating customer order",
error=str(e), tenant_id=tenant_id)
return None
async def update_customer_order(self, tenant_id: str, order_id: str, order_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update an existing customer order"""
try:
result = await self.put(f"orders/list/{order_id}", data=order_data, tenant_id=tenant_id)
if result:
logger.info("Updated customer order",
order_id=order_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error updating customer order",
error=str(e), order_id=order_id, tenant_id=tenant_id)
return None
# ================================================================
# CENTRAL BAKERY ORDERS
# ================================================================
async def get_daily_finalized_orders(self, tenant_id: str, date: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get daily finalized orders for central bakery"""
try:
params = {}
if date:
params["date"] = date
result = await self.get("orders/daily-finalized", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved daily finalized orders from orders service",
date=date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting daily finalized orders",
error=str(e), tenant_id=tenant_id)
return None
async def get_weekly_order_summaries(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get weekly order summaries for central bakery dashboard"""
try:
result = await self.get("orders/dashboard/weekly-summaries", tenant_id=tenant_id)
if result:
logger.info("Retrieved weekly order summaries from orders service",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting weekly order summaries",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# DASHBOARD AND ANALYTICS
# ================================================================
async def get_dashboard_summary(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get orders dashboard summary data"""
try:
result = await self.get("orders/dashboard/summary", tenant_id=tenant_id)
if result:
logger.info("Retrieved orders dashboard summary",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting orders dashboard summary",
error=str(e), tenant_id=tenant_id)
return None
async def get_order_trends(self, tenant_id: str, start_date: str, end_date: str) -> Optional[Dict[str, Any]]:
"""Get order trends analysis"""
try:
params = {
"start_date": start_date,
"end_date": end_date
}
result = await self.get("orders/analytics/trends", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved order trends from orders service",
start_date=start_date, end_date=end_date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting order trends",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# ALERTS AND NOTIFICATIONS
# ================================================================
async def get_central_bakery_alerts(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get central bakery specific alerts"""
try:
result = await self.get("orders/alerts", tenant_id=tenant_id)
alerts = result.get('alerts', []) if result else []
logger.info("Retrieved central bakery alerts from orders service",
alerts_count=len(alerts), tenant_id=tenant_id)
return alerts
except Exception as e:
logger.error("Error getting central bakery alerts",
error=str(e), tenant_id=tenant_id)
return []
async def acknowledge_alert(self, tenant_id: str, alert_id: str) -> Optional[Dict[str, Any]]:
"""Acknowledge an order-related alert"""
try:
result = await self.post(f"orders/alerts/{alert_id}/acknowledge", data={}, tenant_id=tenant_id)
if result:
logger.info("Acknowledged order alert",
alert_id=alert_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error acknowledging order alert",
error=str(e), alert_id=alert_id, tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================
async def download_orders_pdf(self, tenant_id: str, order_ids: List[str], format_type: str = "supplier_communication") -> Optional[bytes]:
"""Download orders as PDF for supplier communication"""
try:
data = {
"order_ids": order_ids,
"format": format_type,
"include_delivery_schedule": True
}
# Note: This would need special handling for binary data
result = await self.post("orders/operations/download-pdf", data=data, tenant_id=tenant_id)
if result:
logger.info("Generated orders PDF",
orders_count=len(order_ids), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error generating orders PDF",
error=str(e), tenant_id=tenant_id)
return None
async def health_check(self) -> bool:
"""Check if orders service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Orders service health check failed", error=str(e))
return False
# Factory function for dependency injection
def create_orders_client(config: BaseServiceSettings) -> OrdersServiceClient:
"""Create orders service client instance"""
return OrdersServiceClient(config)

140
shared/clients/payment_client.py Executable file
View File

@@ -0,0 +1,140 @@
"""
Payment Client Interface and Implementation
This module provides an abstraction layer for payment providers to make the system payment-agnostic
"""
import abc
from typing import Dict, Any, Optional
from dataclasses import dataclass
from datetime import datetime
@dataclass
class PaymentCustomer:
id: str
email: str
name: str
created_at: datetime
@dataclass
class PaymentMethod:
id: str
type: str
brand: Optional[str] = None
last4: Optional[str] = None
exp_month: Optional[int] = None
exp_year: Optional[int] = None
@dataclass
class Subscription:
id: str
customer_id: str
plan_id: str
status: str # active, canceled, past_due, etc.
current_period_start: datetime
current_period_end: datetime
created_at: datetime
billing_cycle_anchor: Optional[datetime] = None
cancel_at_period_end: Optional[bool] = None
# 3DS Authentication fields
payment_intent_id: Optional[str] = None
payment_intent_status: Optional[str] = None
payment_intent_client_secret: Optional[str] = None
requires_action: Optional[bool] = None
trial_end: Optional[datetime] = None
billing_interval: Optional[str] = None
@dataclass
class Invoice:
id: str
customer_id: str
subscription_id: str
amount: float
currency: str
status: str # draft, open, paid, void, etc.
created_at: datetime
due_date: Optional[datetime] = None
description: Optional[str] = None
invoice_pdf: Optional[str] = None # URL to PDF invoice
hosted_invoice_url: Optional[str] = None # URL to hosted invoice page
class PaymentProvider(abc.ABC):
"""
Abstract base class for payment providers.
All payment providers should implement this interface.
"""
@abc.abstractmethod
async def create_customer(self, customer_data: Dict[str, Any]) -> PaymentCustomer:
"""
Create a customer in the payment provider system
"""
pass
@abc.abstractmethod
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription:
"""
Create a subscription for a customer
"""
pass
@abc.abstractmethod
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
"""
Update the payment method for a customer
"""
pass
@abc.abstractmethod
async def cancel_subscription(
self,
subscription_id: str,
cancel_at_period_end: bool = True
) -> Subscription:
"""
Cancel a subscription
Args:
subscription_id: Subscription ID to cancel
cancel_at_period_end: If True, cancel at end of billing period. Default True.
"""
pass
@abc.abstractmethod
async def get_invoices(self, customer_id: str) -> list[Invoice]:
"""
Get invoices for a customer
"""
pass
@abc.abstractmethod
async def get_subscription(self, subscription_id: str) -> Subscription:
"""
Get subscription details
"""
pass
@abc.abstractmethod
async def get_customer(self, customer_id: str) -> PaymentCustomer:
"""
Get customer details
"""
pass
@abc.abstractmethod
async def create_setup_intent(self) -> Dict[str, Any]:
"""
Create a setup intent for saving payment methods
"""
pass
@abc.abstractmethod
async def create_payment_intent(self, amount: float, currency: str, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
"""
Create a payment intent for one-time payments
"""
pass

View File

@@ -0,0 +1,160 @@
"""
Payment Provider Interface
Abstract base class for payment provider implementations
Allows easy swapping of payment SDKs (Stripe, PayPal, etc.)
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
class PaymentProvider(ABC):
"""
Abstract Payment Provider Interface
Define all required methods for payment processing
"""
@abstractmethod
async def create_customer(
self,
email: str,
name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Create a customer in the payment provider"""
pass
@abstractmethod
async def attach_payment_method(
self,
payment_method_id: str,
customer_id: str
) -> Dict[str, Any]:
"""Attach a payment method to a customer"""
pass
@abstractmethod
async def set_default_payment_method(
self,
customer_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""Set the default payment method for a customer"""
pass
@abstractmethod
async def create_setup_intent_for_verification(
self,
customer_id: str,
payment_method_id: str,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Create a SetupIntent for payment method verification (3DS support)"""
pass
@abstractmethod
async def verify_setup_intent_status(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""Verify the status of a SetupIntent"""
pass
@abstractmethod
async def create_subscription_with_verified_payment(
self,
customer_id: str,
price_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None,
billing_cycle_anchor: Optional[Any] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Create a subscription with a verified payment method
Args:
billing_cycle_anchor: Can be int (Unix timestamp), "now", or "unchanged"
"""
pass
@abstractmethod
async def create_setup_intent(self) -> Dict[str, Any]:
"""Create a basic SetupIntent"""
pass
@abstractmethod
async def get_setup_intent(
self,
setup_intent_id: str
) -> Any:
"""Get SetupIntent details"""
pass
@abstractmethod
async def create_payment_intent(
self,
amount: float,
currency: str,
customer_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""Create a PaymentIntent for one-time payments"""
pass
@abstractmethod
async def complete_subscription_after_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""Complete subscription creation after SetupIntent verification"""
pass
@abstractmethod
async def cancel_subscription(
self,
subscription_id: str
) -> Dict[str, Any]:
"""Cancel a subscription"""
pass
@abstractmethod
async def update_payment_method(
self,
customer_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""Update customer's payment method"""
pass
@abstractmethod
async def update_subscription(
self,
subscription_id: str,
new_price_id: str
) -> Dict[str, Any]:
"""Update subscription price"""
pass
@abstractmethod
async def get_subscription(
self,
subscription_id: str
) -> Dict[str, Any]:
"""Get subscription details"""
pass
@abstractmethod
async def get_customer_payment_method(
self,
customer_id: str
) -> Dict[str, Any]:
"""Get customer's payment method"""
pass
@abstractmethod
async def get_invoices(
self,
customer_id: str
) -> Dict[str, Any]:
"""Get customer invoices"""
pass

View File

@@ -0,0 +1,678 @@
"""
Procurement Service Client for Inter-Service Communication
Provides API client for procurement operations and internal transfers
"""
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 ProcurementServiceClient(BaseServiceClient):
"""Client for communicating with the Procurement Service"""
def __init__(self, config: BaseServiceSettings, service_name: str = "unknown"):
super().__init__(service_name, config)
self.service_base_url = config.PROCUREMENT_SERVICE_URL
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# PURCHASE ORDER ENDPOINTS
# ================================================================
async def create_purchase_order(
self,
tenant_id: str,
order_data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""
Create a new purchase order
Args:
tenant_id: Tenant ID
order_data: Purchase order data
Returns:
Created purchase order
"""
try:
response = await self.post(
"procurement/purchase-orders",
data=order_data,
tenant_id=tenant_id
)
if response:
logger.info("Created purchase order",
tenant_id=tenant_id,
po_number=response.get("po_number"))
return response
except Exception as e:
logger.error("Error creating purchase order",
tenant_id=tenant_id,
error=str(e))
return None
async def get_purchase_order(
self,
tenant_id: str,
po_id: str
) -> Optional[Dict[str, Any]]:
"""
Get a specific purchase order
Args:
tenant_id: Tenant ID
po_id: Purchase order ID
Returns:
Purchase order details
"""
try:
response = await self.get(
f"procurement/purchase-orders/{po_id}",
tenant_id=tenant_id
)
if response:
logger.info("Retrieved purchase order",
tenant_id=tenant_id,
po_id=po_id)
return response
except Exception as e:
logger.error("Error getting purchase order",
tenant_id=tenant_id,
po_id=po_id,
error=str(e))
return None
async def update_purchase_order_status(
self,
tenant_id: str,
po_id: str,
new_status: str,
user_id: str
) -> Optional[Dict[str, Any]]:
"""
Update purchase order status
Args:
tenant_id: Tenant ID
po_id: Purchase order ID
new_status: New status
user_id: User ID performing update
Returns:
Updated purchase order
"""
try:
response = await self.put(
f"procurement/purchase-orders/{po_id}/status",
data={
"status": new_status,
"updated_by_user_id": user_id
},
tenant_id=tenant_id
)
if response:
logger.info("Updated purchase order status",
tenant_id=tenant_id,
po_id=po_id,
new_status=new_status)
return response
except Exception as e:
logger.error("Error updating purchase order status",
tenant_id=tenant_id,
po_id=po_id,
new_status=new_status,
error=str(e))
return None
async def get_pending_purchase_orders(
self,
tenant_id: str,
limit: int = 50,
enrich_supplier: bool = True
) -> Optional[List[Dict[str, Any]]]:
"""
Get pending purchase orders
Args:
tenant_id: Tenant ID
limit: Maximum number of results
enrich_supplier: Whether to include supplier details (default: True)
Set to False for faster queries when supplier data will be fetched separately
Returns:
List of pending purchase orders
"""
try:
response = await self.get(
"procurement/purchase-orders",
params={
"status": "pending_approval",
"limit": limit,
"enrich_supplier": enrich_supplier
},
tenant_id=tenant_id
)
if response:
logger.info("Retrieved pending purchase orders",
tenant_id=tenant_id,
count=len(response),
enriched=enrich_supplier)
return response if response else []
except Exception as e:
logger.error("Error getting pending purchase orders",
tenant_id=tenant_id,
error=str(e))
return []
async def get_purchase_orders_by_supplier(
self,
tenant_id: str,
supplier_id: str,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
status: Optional[str] = None,
limit: int = 100
) -> Optional[List[Dict[str, Any]]]:
"""
Get purchase orders for a specific supplier
Args:
tenant_id: Tenant ID
supplier_id: Supplier ID to filter by
date_from: Start date for filtering
date_to: End date for filtering
status: Status filter (e.g., 'approved', 'delivered')
limit: Maximum number of results
Returns:
List of purchase orders with items
"""
try:
params = {
"supplier_id": supplier_id,
"limit": limit
}
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(
"procurement/purchase-orders",
params=params,
tenant_id=tenant_id
)
if response:
logger.info("Retrieved purchase orders by supplier",
tenant_id=tenant_id,
supplier_id=supplier_id,
count=len(response))
return response if response else []
except Exception as e:
logger.error("Error getting purchase orders by supplier",
tenant_id=tenant_id,
supplier_id=supplier_id,
error=str(e))
return []
# ================================================================
# INTERNAL TRANSFER ENDPOINTS (NEW FOR ENTERPRISE TIER)
# ================================================================
async def create_internal_purchase_order(
self,
parent_tenant_id: str,
child_tenant_id: str,
items: List[Dict[str, Any]],
delivery_date: date,
notes: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Create an internal purchase order from parent to child tenant
Args:
parent_tenant_id: Parent tenant ID (supplier)
child_tenant_id: Child tenant ID (buyer)
items: List of items with product_id, quantity, unit_of_measure
delivery_date: When child needs delivery
notes: Optional notes for the transfer
Returns:
Created internal purchase order
"""
try:
response = await self.post(
"procurement/internal-transfers",
data={
"destination_tenant_id": child_tenant_id,
"items": items,
"delivery_date": delivery_date.isoformat(),
"notes": notes
},
tenant_id=parent_tenant_id
)
if response:
logger.info("Created internal purchase order",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
po_number=response.get("po_number"))
return response
except Exception as e:
logger.error("Error creating internal purchase order",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
error=str(e))
return None
async def get_approved_internal_purchase_orders(
self,
parent_tenant_id: str,
target_date: Optional[date] = None,
status: Optional[str] = "approved"
) -> Optional[List[Dict[str, Any]]]:
"""
Get approved internal purchase orders for parent tenant
Args:
parent_tenant_id: Parent tenant ID
target_date: Optional target date to filter
status: Status filter (default: approved)
Returns:
List of approved internal purchase orders
"""
try:
params = {"status": status}
if target_date:
params["target_date"] = target_date.isoformat()
response = await self.get(
"procurement/internal-transfers",
params=params,
tenant_id=parent_tenant_id
)
if response:
logger.info("Retrieved internal purchase orders",
parent_tenant_id=parent_tenant_id,
count=len(response))
return response if response else []
except Exception as e:
logger.error("Error getting internal purchase orders",
parent_tenant_id=parent_tenant_id,
error=str(e))
return []
async def approve_internal_purchase_order(
self,
parent_tenant_id: str,
po_id: str,
approved_by_user_id: str
) -> Optional[Dict[str, Any]]:
"""
Approve an internal purchase order
Args:
parent_tenant_id: Parent tenant ID
po_id: Purchase order ID to approve
approved_by_user_id: User ID performing approval
Returns:
Updated purchase order
"""
try:
response = await self.post(
f"procurement/internal-transfers/{po_id}/approve",
data={
"approved_by_user_id": approved_by_user_id
},
tenant_id=parent_tenant_id
)
if response:
logger.info("Approved internal purchase order",
parent_tenant_id=parent_tenant_id,
po_id=po_id)
return response
except Exception as e:
logger.error("Error approving internal purchase order",
parent_tenant_id=parent_tenant_id,
po_id=po_id,
error=str(e))
return None
async def get_internal_transfer_history(
self,
tenant_id: str,
parent_tenant_id: Optional[str] = None,
child_tenant_id: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get internal transfer history with optional filtering
Args:
tenant_id: Tenant ID (either parent or child)
parent_tenant_id: Filter by specific parent tenant
child_tenant_id: Filter by specific child tenant
start_date: Filter by start date
end_date: Filter by end date
Returns:
List of internal transfer records
"""
try:
params = {}
if parent_tenant_id:
params["parent_tenant_id"] = parent_tenant_id
if child_tenant_id:
params["child_tenant_id"] = child_tenant_id
if start_date:
params["start_date"] = start_date.isoformat()
if end_date:
params["end_date"] = end_date.isoformat()
response = await self.get(
"procurement/internal-transfers/history",
params=params,
tenant_id=tenant_id
)
if response:
logger.info("Retrieved internal transfer history",
tenant_id=tenant_id,
count=len(response))
return response if response else []
except Exception as e:
logger.error("Error getting internal transfer history",
tenant_id=tenant_id,
error=str(e))
return []
# ================================================================
# PROCUREMENT PLAN ENDPOINTS
# ================================================================
async def get_procurement_plan(
self,
tenant_id: str,
plan_id: str
) -> Optional[Dict[str, Any]]:
"""
Get a specific procurement plan
Args:
tenant_id: Tenant ID
plan_id: Procurement plan ID
Returns:
Procurement plan details
"""
try:
response = await self.get(
f"procurement/plans/{plan_id}",
tenant_id=tenant_id
)
if response:
logger.info("Retrieved procurement plan",
tenant_id=tenant_id,
plan_id=plan_id)
return response
except Exception as e:
logger.error("Error getting procurement plan",
tenant_id=tenant_id,
plan_id=plan_id,
error=str(e))
return None
async def get_procurement_plans(
self,
tenant_id: str,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
status: Optional[str] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get procurement plans 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 procurement plan 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(
"procurement/plans",
params=params,
tenant_id=tenant_id
)
if response:
logger.info("Retrieved procurement plans",
tenant_id=tenant_id,
count=len(response))
return response if response else []
except Exception as e:
logger.error("Error getting procurement plans",
tenant_id=tenant_id,
error=str(e))
return []
# ================================================================
# SUPPLIER ENDPOINTS
# ================================================================
async def get_suppliers(
self,
tenant_id: str
) -> Optional[List[Dict[str, Any]]]:
"""
Get suppliers for a tenant
Args:
tenant_id: Tenant ID
Returns:
List of supplier dictionaries
"""
try:
response = await self.get(
"procurement/suppliers",
tenant_id=tenant_id
)
if response:
logger.info("Retrieved suppliers",
tenant_id=tenant_id,
count=len(response))
return response if response else []
except Exception as e:
logger.error("Error getting suppliers",
tenant_id=tenant_id,
error=str(e))
return []
async def get_supplier(
self,
tenant_id: str,
supplier_id: str
) -> Optional[Dict[str, Any]]:
"""
Get specific supplier details
Args:
tenant_id: Tenant ID
supplier_id: Supplier ID
Returns:
Supplier details
"""
try:
# Use suppliers service to get supplier details
from shared.clients.suppliers_client import SuppliersServiceClient
suppliers_client = SuppliersServiceClient(self.config)
response = await suppliers_client.get_supplier_by_id(tenant_id, supplier_id)
if response:
logger.info("Retrieved supplier details",
tenant_id=tenant_id,
supplier_id=supplier_id)
return response
except Exception as e:
logger.error("Error getting supplier details",
tenant_id=tenant_id,
supplier_id=supplier_id,
error=str(e))
return None
# ================================================================
# UTILITIES
# ================================================================
async def health_check(self) -> bool:
"""Check if procurement 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("Procurement service health check failed", error=str(e))
return False
# ================================================================
# INTERNAL TRIGGER METHODS
# ================================================================
async def trigger_delivery_tracking_internal(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Trigger delivery tracking for a tenant (internal service use only).
This method calls the internal endpoint which is protected by x-internal-service header.
Args:
tenant_id: Tenant ID to trigger delivery tracking for
Returns:
Dict with trigger results or None if failed
"""
try:
# Call internal endpoint via gateway using tenant-scoped URL pattern
# Endpoint: /api/v1/tenants/{tenant_id}/procurement/internal/delivery-tracking/trigger
result = await self._make_request(
method="POST",
endpoint="procurement/internal/delivery-tracking/trigger",
tenant_id=tenant_id,
data={},
headers={"x-internal-service": "demo-session"}
)
if result:
logger.info(
"Delivery tracking triggered successfully via internal endpoint",
tenant_id=tenant_id,
alerts_generated=result.get("alerts_generated", 0)
)
else:
logger.warning(
"Delivery tracking internal endpoint returned no result",
tenant_id=tenant_id
)
return result
except Exception as e:
logger.error(
"Error triggering delivery tracking via internal endpoint",
tenant_id=tenant_id,
error=str(e)
)
return None
# ================================================================
# INTERNAL AI INSIGHTS METHODS
# ================================================================
async def trigger_price_insights_internal(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Trigger price forecasting insights for a tenant (internal service use only).
This method calls the internal endpoint which is protected by x-internal-service header.
Args:
tenant_id: Tenant ID to trigger insights for
Returns:
Dict with trigger results or None if failed
"""
try:
result = await self._make_request(
method="POST",
endpoint="procurement/internal/ml/generate-price-insights",
tenant_id=tenant_id,
data={"tenant_id": tenant_id},
headers={"x-internal-service": "demo-session"}
)
if result:
logger.info(
"Price insights triggered successfully via internal endpoint",
tenant_id=tenant_id,
insights_posted=result.get("insights_posted", 0)
)
else:
logger.warning(
"Price insights internal endpoint returned no result",
tenant_id=tenant_id
)
return result
except Exception as e:
logger.error(
"Error triggering price insights via internal endpoint",
tenant_id=tenant_id,
error=str(e)
)
return None
# Factory function for dependency injection
def create_procurement_client(config: BaseServiceSettings, service_name: str = "unknown") -> ProcurementServiceClient:
"""Create procurement service client instance"""
return ProcurementServiceClient(config, service_name)

View File

@@ -0,0 +1,729 @@
# shared/clients/production_client.py
"""
Production Service Client for Inter-Service Communication
Provides access to production planning and batch management from other services
"""
import structlog
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
logger = structlog.get_logger()
class ProductionServiceClient(BaseServiceClient):
"""Client for communicating with the Production Service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# PRODUCTION PLANNING
# ================================================================
async def generate_schedule(
self,
tenant_id: str,
forecast_data: Dict[str, Any],
inventory_data: Optional[Dict[str, Any]] = None,
recipes_data: Optional[Dict[str, Any]] = None,
target_date: Optional[str] = None,
planning_horizon_days: int = 1
) -> Optional[Dict[str, Any]]:
"""
Generate production schedule (called by Orchestrator).
Args:
tenant_id: Tenant ID
forecast_data: Forecast data from forecasting service
inventory_data: Optional inventory snapshot (NEW - to avoid duplicate fetching)
recipes_data: Optional recipes snapshot (NEW - to avoid duplicate fetching)
target_date: Optional target date
planning_horizon_days: Number of days to plan
Returns:
Dict with schedule_id, batches_created, etc.
"""
try:
request_data = {
"forecast_data": forecast_data,
"target_date": target_date,
"planning_horizon_days": planning_horizon_days
}
# NEW: Include cached data if provided
if inventory_data:
request_data["inventory_data"] = inventory_data
if recipes_data:
request_data["recipes_data"] = recipes_data
result = await self.post(
"production/operations/generate-schedule",
data=request_data,
tenant_id=tenant_id
)
if result:
logger.info(
"Generated production schedule",
schedule_id=result.get('schedule_id'),
batches_created=result.get('batches_created', 0),
tenant_id=tenant_id
)
return result
except Exception as e:
logger.error(
"Error generating production schedule",
error=str(e),
tenant_id=tenant_id
)
return None
async def get_production_requirements(self, tenant_id: str, date: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get production requirements for procurement planning"""
try:
params = {}
if date:
params["date"] = date
result = await self.get("production/requirements", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved production requirements from production service",
date=date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting production requirements",
error=str(e), tenant_id=tenant_id)
return None
async def get_daily_requirements(self, tenant_id: str, date: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get daily production requirements"""
try:
params = {}
if date:
params["date"] = date
result = await self.get("production/daily-requirements", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved daily production requirements from production service",
date=date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting daily production requirements",
error=str(e), tenant_id=tenant_id)
return None
async def get_production_schedule(self, tenant_id: str, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get production schedule for a date range"""
try:
params = {}
if start_date:
params["start_date"] = start_date
if end_date:
params["end_date"] = end_date
result = await self.get("production/schedules", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved production schedule from production service",
start_date=start_date, end_date=end_date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting production schedule",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# BATCH MANAGEMENT
# ================================================================
async def get_active_batches(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get currently active production batches"""
try:
result = await self.get("production/batches/active", tenant_id=tenant_id)
batches = result.get('batches', []) if result else []
logger.info("Retrieved active production batches from production service",
batches_count=len(batches), tenant_id=tenant_id)
return batches
except Exception as e:
logger.error("Error getting active production batches",
error=str(e), tenant_id=tenant_id)
return []
async def create_production_batch(self, tenant_id: str, batch_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Create a new production batch"""
try:
result = await self.post("production/batches", data=batch_data, tenant_id=tenant_id)
if result:
logger.info("Created production batch",
batch_id=result.get('id'),
product_id=batch_data.get('product_id'),
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error creating production batch",
error=str(e), tenant_id=tenant_id)
return None
async def update_batch_status(self, tenant_id: str, batch_id: str, status: str, actual_quantity: Optional[float] = None) -> Optional[Dict[str, Any]]:
"""Update production batch status"""
try:
data = {"status": status}
if actual_quantity is not None:
data["actual_quantity"] = actual_quantity
result = await self.put(f"production/batches/{batch_id}/status", data=data, tenant_id=tenant_id)
if result:
logger.info("Updated production batch status",
batch_id=batch_id, status=status, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error updating production batch status",
error=str(e), batch_id=batch_id, tenant_id=tenant_id)
return None
async def get_batch_details(self, tenant_id: str, batch_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed information about a production batch"""
try:
result = await self.get(f"production/batches/{batch_id}", tenant_id=tenant_id)
if result:
logger.info("Retrieved production batch details",
batch_id=batch_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting production batch details",
error=str(e), batch_id=batch_id, tenant_id=tenant_id)
return None
# ================================================================
# CAPACITY MANAGEMENT
# ================================================================
async def get_capacity_status(self, tenant_id: str, date: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get production capacity status for a specific date"""
try:
params = {}
if date:
params["date"] = date
result = await self.get("production/capacity/status", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved production capacity status",
date=date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting production capacity status",
error=str(e), tenant_id=tenant_id)
return None
async def check_capacity_availability(self, tenant_id: str, requirements: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Check if production capacity is available for requirements"""
try:
result = await self.post("production/capacity/check-availability",
{"requirements": requirements},
tenant_id=tenant_id)
if result:
logger.info("Checked production capacity availability",
requirements_count=len(requirements), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error checking production capacity availability",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# QUALITY CONTROL
# ================================================================
async def record_quality_check(self, tenant_id: str, batch_id: str, quality_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Record quality control results for a batch"""
try:
result = await self.post(f"production/batches/{batch_id}/quality-check",
data=quality_data,
tenant_id=tenant_id)
if result:
logger.info("Recorded quality check for production batch",
batch_id=batch_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error recording quality check",
error=str(e), batch_id=batch_id, tenant_id=tenant_id)
return None
async def get_yield_metrics(self, tenant_id: str, start_date: str, end_date: str) -> Optional[Dict[str, Any]]:
"""Get production yield metrics for analysis"""
try:
params = {
"start_date": start_date,
"end_date": end_date
}
result = await self.get("production/analytics/yield-metrics", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved production yield metrics",
start_date=start_date, end_date=end_date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting production yield metrics",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# DASHBOARD AND ANALYTICS
# ================================================================
async def get_dashboard_summary(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get production dashboard summary data"""
try:
result = await self.get("production/dashboard/summary", tenant_id=tenant_id)
if result:
logger.info("Retrieved production dashboard summary",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting production dashboard summary",
error=str(e), tenant_id=tenant_id)
return None
async def get_efficiency_metrics(self, tenant_id: str, period: str = "last_30_days") -> Optional[Dict[str, Any]]:
"""Get production efficiency metrics"""
try:
params = {"period": period}
result = await self.get("production/analytics/efficiency", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved production efficiency metrics",
period=period, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting production efficiency metrics",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# ALERTS AND NOTIFICATIONS
# ================================================================
async def get_production_alerts(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get production-related alerts"""
try:
result = await self.get("production/alerts", tenant_id=tenant_id)
alerts = result.get('alerts', []) if result else []
logger.info("Retrieved production alerts",
alerts_count=len(alerts), tenant_id=tenant_id)
return alerts
except Exception as e:
logger.error("Error getting production alerts",
error=str(e), tenant_id=tenant_id)
return []
async def acknowledge_alert(self, tenant_id: str, alert_id: str) -> Optional[Dict[str, Any]]:
"""Acknowledge a production-related alert"""
try:
result = await self.post(f"production/alerts/{alert_id}/acknowledge", data={}, tenant_id=tenant_id)
if result:
logger.info("Acknowledged production alert",
alert_id=alert_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error acknowledging production alert",
error=str(e), alert_id=alert_id, tenant_id=tenant_id)
return None
# ================================================================
# WASTE AND SUSTAINABILITY ANALYTICS
# ================================================================
async def get_waste_analytics(
self,
tenant_id: str,
start_date: str,
end_date: str
) -> Optional[Dict[str, Any]]:
"""
Get production waste analytics for sustainability reporting
Args:
tenant_id: Tenant ID
start_date: Start date (ISO format)
end_date: End date (ISO format)
Returns:
Dictionary with waste analytics data:
- total_production_waste: Total waste in kg
- total_defects: Total defect waste in kg
- total_planned: Total planned production in kg
- total_actual: Total actual production in kg
- ai_assisted_batches: Number of AI-assisted batches
"""
try:
params = {
"start_date": start_date,
"end_date": end_date
}
result = await self.get("production/waste-analytics", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved production waste analytics",
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date)
return result
except Exception as e:
logger.error("Error getting production waste analytics",
error=str(e), tenant_id=tenant_id)
return None
async def get_baseline(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get baseline waste percentage for SDG compliance calculations
Args:
tenant_id: Tenant ID
Returns:
Dictionary with baseline data:
- waste_percentage: Baseline waste percentage
- period: Information about the baseline period
- data_available: Whether real data is available
- total_production_kg: Total production during baseline
- total_waste_kg: Total waste during baseline
"""
try:
result = await self.get("production/baseline", tenant_id=tenant_id)
if result:
logger.info("Retrieved production baseline data",
tenant_id=tenant_id,
data_available=result.get('data_available', False))
return result
except Exception as e:
logger.error("Error getting production baseline",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# ML INSIGHTS: Yield Prediction
# ================================================================
async def trigger_yield_prediction(
self,
tenant_id: str,
recipe_ids: Optional[List[str]] = None,
lookback_days: int = 90,
min_history_runs: int = 30
) -> Optional[Dict[str, Any]]:
"""
Trigger yield prediction for production recipes.
Args:
tenant_id: Tenant UUID
recipe_ids: Specific recipe IDs to analyze. If None, analyzes all recipes
lookback_days: Days of historical production to analyze (30-365)
min_history_runs: Minimum production runs required (10-100)
Returns:
Dict with prediction results including insights posted
"""
try:
data = {
"recipe_ids": recipe_ids,
"lookback_days": lookback_days,
"min_history_runs": min_history_runs
}
result = await self.post("production/ml/insights/predict-yields", data=data, tenant_id=tenant_id)
if result:
logger.info("Triggered yield prediction",
recipes_analyzed=result.get('recipes_analyzed', 0),
insights_posted=result.get('total_insights_posted', 0),
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error triggering yield prediction",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# DASHBOARD METHODS
# ================================================================
async def get_production_summary_batch(
self,
tenant_ids: List[str]
) -> Dict[str, Any]:
"""
Get production summaries for multiple tenants in a single request.
Phase 2 optimization: Eliminates N+1 query patterns for enterprise dashboards.
Args:
tenant_ids: List of tenant IDs to fetch
Returns:
Dict mapping tenant_id -> production summary
"""
try:
if not tenant_ids:
return {}
if len(tenant_ids) > 100:
logger.warning("Batch request exceeds max tenant limit", requested=len(tenant_ids))
tenant_ids = tenant_ids[:100]
result = await self.post(
"production/batch/production-summary",
data={"tenant_ids": tenant_ids},
tenant_id=tenant_ids[0] # Use first tenant for auth context
)
summaries = result if isinstance(result, dict) else {}
logger.info(
"Batch retrieved production summaries",
requested=len(tenant_ids),
found=len(summaries)
)
return summaries
except Exception as e:
logger.error(
"Error batch fetching production summaries",
error=str(e),
tenant_count=len(tenant_ids)
)
return {}
async def get_todays_batches(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Get today's production batches for dashboard timeline
For demo compatibility: Queries all recent batches and filters for actionable ones
scheduled for today, since demo session dates are adjusted relative to session creation time.
Args:
tenant_id: Tenant ID
Returns:
Dict with ProductionBatchListResponse: {"batches": [...], "total_count": n, "page": 1, "page_size": n}
"""
try:
from datetime import datetime, timezone, timedelta
# Get today's date range (start of day to end of day in UTC)
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
# Query all batches without date/status filter for demo compatibility
# The dashboard will filter for PENDING, IN_PROGRESS, or SCHEDULED
result = await self.get(
"/production/batches",
tenant_id=tenant_id,
params={"page_size": 100}
)
if result and "batches" in result:
# Filter for actionable batches scheduled for TODAY
actionable_statuses = {"PENDING", "IN_PROGRESS", "SCHEDULED"}
filtered_batches = []
for batch in result["batches"]:
# Check if batch is actionable
if batch.get("status") not in actionable_statuses:
continue
# Check if batch is scheduled for today
# Include batches that START today OR END today (for overnight batches)
planned_start = batch.get("planned_start_time")
planned_end = batch.get("planned_end_time")
include_batch = False
if planned_start:
# Parse the start date string
if isinstance(planned_start, str):
planned_start = datetime.fromisoformat(planned_start.replace('Z', '+00:00'))
# Include if batch starts today
if today_start <= planned_start < today_end:
include_batch = True
# Also check if batch ends today (for overnight batches)
if not include_batch and planned_end:
if isinstance(planned_end, str):
planned_end = datetime.fromisoformat(planned_end.replace('Z', '+00:00'))
# Include if batch ends today (even if it started yesterday)
if today_start <= planned_end < today_end:
include_batch = True
if include_batch:
filtered_batches.append(batch)
# Return filtered result
return {
**result,
"batches": filtered_batches,
"total_count": len(filtered_batches)
}
return result
except Exception as e:
logger.error("Error fetching today's batches", error=str(e), tenant_id=tenant_id)
return None
async def get_production_batches_by_status(
self,
tenant_id: str,
status: str,
limit: int = 100
) -> Optional[Dict[str, Any]]:
"""
Get production batches filtered by status for dashboard
Args:
tenant_id: Tenant ID
status: Batch status (e.g., "ON_HOLD", "IN_PROGRESS")
limit: Maximum number of batches to return
Returns:
Dict with ProductionBatchListResponse: {"batches": [...], "total_count": n, "page": 1, "page_size": n}
"""
try:
return await self.get(
"/production/batches",
tenant_id=tenant_id,
params={"status": status, "page_size": limit}
)
except Exception as e:
logger.error("Error fetching production batches", error=str(e),
status=status, tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if production service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Production service health check failed", error=str(e))
return False
# ================================================================
# INTERNAL TRIGGER METHODS
# ================================================================
async def trigger_production_alerts_internal(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Trigger production alerts for a tenant (internal service use only).
This method calls the internal endpoint which is protected by x-internal-service header.
Includes both production alerts and equipment maintenance checks.
Args:
tenant_id: Tenant ID to trigger alerts for
Returns:
Dict with trigger results or None if failed
"""
try:
# Call internal endpoint via gateway using tenant-scoped URL pattern
# Endpoint: /api/v1/tenants/{tenant_id}/production/internal/alerts/trigger
result = await self._make_request(
method="POST",
endpoint="production/internal/alerts/trigger",
tenant_id=tenant_id,
data={},
headers={"x-internal-service": "demo-session"}
)
if result:
logger.info(
"Production alerts triggered successfully via internal endpoint",
tenant_id=tenant_id,
alerts_generated=result.get("alerts_generated", 0)
)
else:
logger.warning(
"Production alerts internal endpoint returned no result",
tenant_id=tenant_id
)
return result
except Exception as e:
logger.error(
"Error triggering production alerts via internal endpoint",
tenant_id=tenant_id,
error=str(e)
)
return None
# ================================================================
# INTERNAL AI INSIGHTS METHODS
# ================================================================
async def trigger_yield_insights_internal(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Trigger yield improvement insights for a tenant (internal service use only).
This method calls the internal endpoint which is protected by x-internal-service header.
Args:
tenant_id: Tenant ID to trigger insights for
Returns:
Dict with trigger results or None if failed
"""
try:
result = await self._make_request(
method="POST",
endpoint="production/internal/ml/generate-yield-insights",
tenant_id=tenant_id,
data={"tenant_id": tenant_id},
headers={"x-internal-service": "demo-session"}
)
if result:
logger.info(
"Yield insights triggered successfully via internal endpoint",
tenant_id=tenant_id,
insights_posted=result.get("insights_posted", 0)
)
else:
logger.warning(
"Yield insights internal endpoint returned no result",
tenant_id=tenant_id
)
return result
except Exception as e:
logger.error(
"Error triggering yield insights via internal endpoint",
tenant_id=tenant_id,
error=str(e)
)
return None
# Factory function for dependency injection
def create_production_client(config: BaseServiceSettings) -> ProductionServiceClient:
"""Create production service client instance"""
return ProductionServiceClient(config)

294
shared/clients/recipes_client.py Executable file
View File

@@ -0,0 +1,294 @@
# shared/clients/recipes_client.py
"""
Recipes Service Client for Inter-Service Communication
Provides access to recipe and ingredient requirements from other services
"""
import structlog
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
logger = structlog.get_logger()
class RecipesServiceClient(BaseServiceClient):
"""Client for communicating with the Recipes Service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# RECIPE MANAGEMENT
# ================================================================
async def get_recipe_by_id(self, tenant_id: str, recipe_id: str) -> Optional[Dict[str, Any]]:
"""Get recipe details by ID"""
try:
result = await self.get(f"recipes/{recipe_id}", tenant_id=tenant_id)
if result:
logger.info("Retrieved recipe details from recipes service",
recipe_id=recipe_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting recipe details",
error=str(e), recipe_id=recipe_id, tenant_id=tenant_id)
return None
async def get_recipes_by_product_ids(self, tenant_id: str, product_ids: List[str]) -> Optional[List[Dict[str, Any]]]:
"""Get recipes for multiple products"""
try:
params = {"product_ids": ",".join(product_ids)}
result = await self.get("recipes/by-products", tenant_id=tenant_id, params=params)
recipes = result.get('recipes', []) if result else []
logger.info("Retrieved recipes by product IDs from recipes service",
product_ids_count=len(product_ids),
recipes_count=len(recipes),
tenant_id=tenant_id)
return recipes
except Exception as e:
logger.error("Error getting recipes by product IDs",
error=str(e), tenant_id=tenant_id)
return []
async def get_all_recipes(self, tenant_id: str, is_active: Optional[bool] = True) -> Optional[List[Dict[str, Any]]]:
"""Get all recipes for a tenant"""
try:
params = {}
if is_active is not None:
params["is_active"] = is_active
result = await self.get_paginated("recipes", tenant_id=tenant_id, params=params)
logger.info("Retrieved all recipes from recipes service",
recipes_count=len(result), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting all recipes",
error=str(e), tenant_id=tenant_id)
return []
# ================================================================
# INGREDIENT REQUIREMENTS
# ================================================================
async def get_recipe_requirements(self, tenant_id: str, recipe_ids: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""Get ingredient requirements for recipes"""
try:
params = {}
if recipe_ids:
params["recipe_ids"] = ",".join(recipe_ids)
result = await self.get("recipes/requirements", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved recipe requirements from recipes service",
recipe_ids_count=len(recipe_ids) if recipe_ids else 0,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting recipe requirements",
error=str(e), tenant_id=tenant_id)
return None
async def get_ingredient_requirements(self, tenant_id: str, product_ids: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""Get ingredient requirements for production planning"""
try:
params = {}
if product_ids:
params["product_ids"] = ",".join(product_ids)
result = await self.get("recipes/ingredient-requirements", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved ingredient requirements from recipes service",
product_ids_count=len(product_ids) if product_ids else 0,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting ingredient requirements",
error=str(e), tenant_id=tenant_id)
return None
async def calculate_ingredients_for_quantity(self, tenant_id: str, recipe_id: str, quantity: float) -> Optional[Dict[str, Any]]:
"""Calculate ingredient quantities needed for a specific production quantity"""
try:
data = {
"recipe_id": recipe_id,
"quantity": quantity
}
result = await self.post("recipes/operations/calculate-ingredients", data=data, tenant_id=tenant_id)
if result:
logger.info("Calculated ingredient quantities from recipes service",
recipe_id=recipe_id, quantity=quantity, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error calculating ingredient quantities",
error=str(e), recipe_id=recipe_id, tenant_id=tenant_id)
return None
async def calculate_batch_ingredients(self, tenant_id: str, production_requests: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Calculate total ingredient requirements for multiple production batches"""
try:
data = {"production_requests": production_requests}
result = await self.post("recipes/operations/calculate-batch-ingredients", data=data, tenant_id=tenant_id)
if result:
logger.info("Calculated batch ingredient requirements from recipes service",
batches_count=len(production_requests), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error calculating batch ingredient requirements",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# PRODUCTION SUPPORT
# ================================================================
async def get_production_instructions(self, tenant_id: str, recipe_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed production instructions for a recipe"""
try:
result = await self.get(f"recipes/{recipe_id}/production-instructions", tenant_id=tenant_id)
if result:
logger.info("Retrieved production instructions from recipes service",
recipe_id=recipe_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting production instructions",
error=str(e), recipe_id=recipe_id, tenant_id=tenant_id)
return None
async def get_recipe_yield_info(self, tenant_id: str, recipe_id: str) -> Optional[Dict[str, Any]]:
"""Get yield information for a recipe"""
try:
result = await self.get(f"recipes/{recipe_id}/yield", tenant_id=tenant_id)
if result:
logger.info("Retrieved recipe yield info from recipes service",
recipe_id=recipe_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting recipe yield info",
error=str(e), recipe_id=recipe_id, tenant_id=tenant_id)
return None
async def validate_recipe_feasibility(self, tenant_id: str, recipe_id: str, quantity: float) -> Optional[Dict[str, Any]]:
"""Validate if a recipe can be produced in the requested quantity"""
try:
data = {
"recipe_id": recipe_id,
"quantity": quantity
}
result = await self.post("recipes/operations/validate-feasibility", data=data, tenant_id=tenant_id)
if result:
logger.info("Validated recipe feasibility from recipes service",
recipe_id=recipe_id, quantity=quantity, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error validating recipe feasibility",
error=str(e), recipe_id=recipe_id, tenant_id=tenant_id)
return None
# ================================================================
# ANALYTICS AND OPTIMIZATION
# ================================================================
async def get_recipe_cost_analysis(self, tenant_id: str, recipe_id: str) -> Optional[Dict[str, Any]]:
"""Get cost analysis for a recipe"""
try:
result = await self.get(f"recipes/{recipe_id}/cost-analysis", tenant_id=tenant_id)
if result:
logger.info("Retrieved recipe cost analysis from recipes service",
recipe_id=recipe_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting recipe cost analysis",
error=str(e), recipe_id=recipe_id, tenant_id=tenant_id)
return None
async def optimize_production_batch(self, tenant_id: str, requirements: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Optimize production batch to minimize waste and cost"""
try:
data = {"requirements": requirements}
result = await self.post("recipes/operations/optimize-batch", data=data, tenant_id=tenant_id)
if result:
logger.info("Optimized production batch from recipes service",
requirements_count=len(requirements), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error optimizing production batch",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# DASHBOARD AND ANALYTICS
# ================================================================
async def get_dashboard_summary(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get recipes dashboard summary data"""
try:
result = await self.get("recipes/dashboard/summary", tenant_id=tenant_id)
if result:
logger.info("Retrieved recipes dashboard summary",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting recipes dashboard summary",
error=str(e), tenant_id=tenant_id)
return None
async def get_popular_recipes(self, tenant_id: str, period: str = "last_30_days") -> Optional[List[Dict[str, Any]]]:
"""Get most popular recipes based on production frequency"""
try:
params = {"period": period}
result = await self.get("recipes/analytics/popular-recipes", tenant_id=tenant_id, params=params)
recipes = result.get('recipes', []) if result else []
logger.info("Retrieved popular recipes from recipes service",
period=period, recipes_count=len(recipes), tenant_id=tenant_id)
return recipes
except Exception as e:
logger.error("Error getting popular recipes",
error=str(e), tenant_id=tenant_id)
return []
# ================================================================
# COUNT AND STATISTICS
# ================================================================
async def count_recipes(self, tenant_id: str) -> int:
"""
Get the count of recipes for a tenant
Used for subscription limit tracking
Returns:
int: Number of recipes for the tenant
"""
try:
result = await self.get("recipes/count", tenant_id=tenant_id)
count = result.get('count', 0) if result else 0
logger.info("Retrieved recipe count from recipes service",
count=count, tenant_id=tenant_id)
return count
except Exception as e:
logger.error("Error getting recipe count",
error=str(e), tenant_id=tenant_id)
return 0
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if recipes service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Recipes service health check failed", error=str(e))
return False
# Factory function for dependency injection
def create_recipes_client(config: BaseServiceSettings, service_name: str = "unknown") -> RecipesServiceClient:
"""Create recipes service client instance"""
return RecipesServiceClient(config, calling_service_name=service_name)

344
shared/clients/sales_client.py Executable file
View File

@@ -0,0 +1,344 @@
# shared/clients/sales_client.py
"""
Sales Service Client
Handles all API calls to the sales service
"""
import httpx
import structlog
from datetime import date
from typing import Dict, Any, Optional, List, Union
from .base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class SalesServiceClient(BaseServiceClient):
"""Client for communicating with the sales service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
self.service_url = config.SALES_SERVICE_URL
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# SALES DATA (with advanced pagination support)
# ================================================================
async def get_sales_data(
self,
tenant_id: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
product_id: Optional[str] = None,
aggregation: str = "daily"
) -> Optional[List[Dict[str, Any]]]:
"""Get sales data for a date range"""
params = {"aggregation": aggregation}
if start_date:
params["start_date"] = start_date
if end_date:
params["end_date"] = end_date
if product_id:
params["product_id"] = product_id
result = await self.get("sales/sales", tenant_id=tenant_id, params=params)
# Handle both list and dict responses
if result is None:
return None
elif isinstance(result, list):
return result
elif isinstance(result, dict):
return result.get("sales", [])
else:
return None
async def get_all_sales_data(
self,
tenant_id: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
product_id: Optional[str] = None,
aggregation: str = "daily",
page_size: int = 1000,
max_pages: int = 100
) -> List[Dict[str, Any]]:
"""
Get ALL sales data using pagination (equivalent to original fetch_sales_data)
Retrieves all records without pagination limits
"""
params = {"aggregation": aggregation}
if start_date:
params["start_date"] = start_date
if end_date:
params["end_date"] = end_date
if product_id:
params["product_id"] = product_id
# Use the inherited paginated request method
try:
all_records = await self.get_paginated(
"sales/sales",
tenant_id=tenant_id,
params=params,
page_size=page_size,
max_pages=max_pages,
timeout=2000.0
)
logger.info(f"Successfully fetched {len(all_records)} total sales records via sales service",
tenant_id=tenant_id)
return all_records
except Exception as e:
logger.error(f"Failed to fetch paginated sales data: {e}")
return []
async def upload_sales_data(
self,
tenant_id: str,
sales_data: List[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
"""Upload sales data"""
data = {"sales": sales_data}
return await self.post("sales/sales", data=data, tenant_id=tenant_id)
# ================================================================
# PRODUCTS
# ================================================================
async def get_products(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get all products for a tenant"""
result = await self.get("sales/products", tenant_id=tenant_id)
return result.get("products", []) if result else None
async def get_product(self, tenant_id: str, product_id: str) -> Optional[Dict[str, Any]]:
"""Get a specific product"""
return await self.get(f"sales/products/{product_id}", tenant_id=tenant_id)
async def create_product(
self,
tenant_id: str,
name: str,
category: str,
price: float,
**kwargs
) -> Optional[Dict[str, Any]]:
"""Create a new product"""
data = {
"name": name,
"category": category,
"price": price,
**kwargs
}
return await self.post("sales/products", data=data, tenant_id=tenant_id)
async def update_product(
self,
tenant_id: str,
product_id: str,
**updates
) -> Optional[Dict[str, Any]]:
"""Update a product"""
return await self.put(f"sales/products/{product_id}", data=updates, tenant_id=tenant_id)
async def create_sales_record(
self,
tenant_id: str,
sales_data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Create a new sales record
Args:
tenant_id: Tenant ID
sales_data: Sales record data including:
- inventory_product_id: Optional UUID for inventory tracking
- product_name: Product name
- product_category: Product category
- quantity_sold: Quantity sold
- unit_price: Unit price
- total_amount: Total amount
- sale_date: Sale date (YYYY-MM-DD)
- sales_channel: Sales channel (retail, wholesale, online, pos, etc.)
- source: Data source (manual, pos_sync, import, etc.)
- payment_method: Payment method
- notes: Optional notes
Returns:
Created sales record or None if failed
"""
try:
result = await self.post("sales/sales", data=sales_data, tenant_id=tenant_id)
if result:
logger.info("Created sales record via client",
tenant_id=tenant_id,
product=sales_data.get("product_name"))
return result
except Exception as e:
logger.error("Failed to create sales record",
error=str(e),
tenant_id=tenant_id)
return None
async def get_sales_summary(
self,
tenant_id: str,
start_date: date,
end_date: date
) -> Dict[str, Any]:
"""
Get sales summary/analytics for a tenant.
This method calls the sales analytics summary endpoint which provides
aggregated sales metrics over a date range.
Args:
tenant_id: The tenant UUID
start_date: Start date for summary range
end_date: End date for summary range
Returns:
Sales summary data including metrics like total sales, revenue, etc.
"""
params = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
}
return await self.get(
"sales/analytics/summary",
tenant_id=tenant_id,
params=params
)
async def get_sales_summary_batch(
self,
tenant_ids: List[str],
start_date: date,
end_date: date
) -> Dict[str, Any]:
"""
Get sales summaries for multiple tenants in a single request.
Phase 2 optimization: Eliminates N+1 query patterns for enterprise dashboards.
Args:
tenant_ids: List of tenant IDs to fetch
start_date: Start date for summary range
end_date: End date for summary range
Returns:
Dict mapping tenant_id -> sales summary
"""
try:
if not tenant_ids:
return {}
if len(tenant_ids) > 100:
logger.warning("Batch request exceeds max tenant limit", requested=len(tenant_ids))
tenant_ids = tenant_ids[:100]
data = {
"tenant_ids": tenant_ids,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
}
result = await self.post(
"sales/batch/sales-summary",
data=data,
tenant_id=tenant_ids[0] # Use first tenant for auth context
)
summaries = result if isinstance(result, dict) else {}
logger.info(
"Batch retrieved sales summaries",
requested=len(tenant_ids),
found=len(summaries),
start_date=start_date.isoformat(),
end_date=end_date.isoformat()
)
return summaries
except Exception as e:
logger.error(
"Error batch fetching sales summaries",
error=str(e),
tenant_count=len(tenant_ids)
)
return {}
async def get_product_demand_patterns(
self,
tenant_id: str,
product_id: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
min_history_days: int = 90
) -> Dict[str, Any]:
"""
Get demand pattern analysis for a specific product.
Args:
tenant_id: Tenant identifier
product_id: Product identifier (inventory_product_id)
start_date: Start date for analysis
end_date: End date for analysis
min_history_days: Minimum days of history required
Returns:
Demand pattern analysis including trends, seasonality, and statistics
"""
try:
params = {"min_history_days": min_history_days}
if start_date:
params["start_date"] = start_date.isoformat()
if end_date:
params["end_date"] = end_date.isoformat()
result = await self.get(
f"sales/analytics/products/{product_id}/demand-patterns",
tenant_id=tenant_id,
params=params
)
logger.info(
"Retrieved product demand patterns",
tenant_id=tenant_id,
product_id=product_id
)
return result if result else {}
except Exception as e:
logger.error(
"Failed to get product demand patterns",
error=str(e),
tenant_id=tenant_id,
product_id=product_id
)
return {}
# ================================================================
# DATA IMPORT
# ================================================================
async def import_sales_data(
self,
tenant_id: str,
file_content: str,
file_format: str,
filename: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Import sales data from CSV/Excel/JSON"""
data = {
"content": file_content,
"format": file_format,
"filename": filename
}
return await self.post("sales/operations/import", data=data, tenant_id=tenant_id)

1754
shared/clients/stripe_client.py Executable file

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(f"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(f"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

@@ -0,0 +1,296 @@
# shared/clients/suppliers_client.py
"""
Suppliers Service Client for Inter-Service Communication
Provides access to supplier data and performance metrics from other services
"""
import structlog
from typing import Dict, Any, Optional, List
from shared.clients.base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class SuppliersServiceClient(BaseServiceClient):
"""Client for communicating with the Suppliers Service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# SUPPLIER MANAGEMENT
# ================================================================
async def get_supplier_by_id(self, tenant_id: str, supplier_id: str) -> Optional[Dict[str, Any]]:
"""Get supplier details by ID"""
try:
result = await self.get(f"suppliers/{supplier_id}", tenant_id=tenant_id)
if result:
logger.info("Retrieved supplier details from suppliers service",
supplier_id=supplier_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting supplier details",
error=str(e), supplier_id=supplier_id, tenant_id=tenant_id)
return None
async def get_all_suppliers(self, tenant_id: str, is_active: Optional[bool] = True) -> Optional[List[Dict[str, Any]]]:
"""Get all suppliers for a tenant"""
try:
params = {}
if is_active is not None:
params["is_active"] = is_active
result = await self.get_paginated("suppliers", tenant_id=tenant_id, params=params)
logger.info("Retrieved all suppliers from suppliers service",
suppliers_count=len(result) if result else 0, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting all suppliers",
error=str(e), tenant_id=tenant_id)
return []
async def search_suppliers(self, tenant_id: str, search: Optional[str] = None, category: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
"""Search suppliers with filters"""
try:
params = {}
if search:
params["search_term"] = search
if category:
params["supplier_type"] = category
result = await self.get("suppliers", tenant_id=tenant_id, params=params)
suppliers = result if result else []
logger.info("Searched suppliers from suppliers service",
search_term=search, suppliers_count=len(suppliers), tenant_id=tenant_id)
return suppliers
except Exception as e:
logger.error("Error searching suppliers",
error=str(e), tenant_id=tenant_id)
return []
async def get_suppliers_batch(self, tenant_id: str, supplier_ids: List[str]) -> Optional[List[Dict[str, Any]]]:
"""
Get multiple suppliers in a single request for performance optimization.
This method eliminates N+1 query patterns when fetching supplier data
for multiple purchase orders or other entities.
Args:
tenant_id: Tenant ID
supplier_ids: List of supplier IDs to fetch
Returns:
List of supplier dictionaries or empty list if error
"""
try:
if not supplier_ids:
return []
# Join IDs as comma-separated string
ids_param = ",".join(supplier_ids)
params = {"ids": ids_param}
result = await self.get("suppliers/batch", tenant_id=tenant_id, params=params)
suppliers = result if result else []
logger.info("Batch retrieved suppliers from suppliers service",
requested_count=len(supplier_ids),
found_count=len(suppliers),
tenant_id=tenant_id)
return suppliers
except Exception as e:
logger.error("Error batch retrieving suppliers",
error=str(e),
requested_count=len(supplier_ids),
tenant_id=tenant_id)
return []
# ================================================================
# SUPPLIER RECOMMENDATIONS
# ================================================================
async def get_supplier_recommendations(self, tenant_id: str, ingredient_id: str) -> Optional[Dict[str, Any]]:
"""Get supplier recommendations for procurement"""
try:
params = {"ingredient_id": ingredient_id}
result = await self.get("suppliers/recommendations", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved supplier recommendations from suppliers service",
ingredient_id=ingredient_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting supplier recommendations",
error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
return None
async def get_best_supplier_for_ingredient(self, tenant_id: str, ingredient_id: str, criteria: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Get best supplier for a specific ingredient based on criteria"""
try:
data = {
"ingredient_id": ingredient_id,
"criteria": criteria or {}
}
result = await self.post("suppliers/operations/find-best-supplier", data=data, tenant_id=tenant_id)
if result:
logger.info("Retrieved best supplier from suppliers service",
ingredient_id=ingredient_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting best supplier for ingredient",
error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
return None
# ================================================================
# PERFORMANCE TRACKING
# ================================================================
async def get_supplier_performance(self, tenant_id: str, supplier_id: str, period: str = "last_30_days") -> Optional[Dict[str, Any]]:
"""Get supplier performance metrics"""
try:
params = {"period": period}
result = await self.get(f"suppliers/analytics/performance/{supplier_id}", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved supplier performance from suppliers service",
supplier_id=supplier_id, period=period, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting supplier performance",
error=str(e), supplier_id=supplier_id, tenant_id=tenant_id)
return None
async def get_performance_alerts(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get supplier performance alerts"""
try:
result = await self.get("suppliers/alerts/performance", tenant_id=tenant_id)
alerts = result.get('alerts', []) if result else []
logger.info("Retrieved supplier performance alerts",
alerts_count=len(alerts), tenant_id=tenant_id)
return alerts
except Exception as e:
logger.error("Error getting supplier performance alerts",
error=str(e), tenant_id=tenant_id)
return []
async def record_supplier_rating(self, tenant_id: str, supplier_id: str, rating_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Record a rating/review for a supplier"""
try:
result = await self.post(f"suppliers/{supplier_id}/rating", data=rating_data, tenant_id=tenant_id)
if result:
logger.info("Recorded supplier rating",
supplier_id=supplier_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error recording supplier rating",
error=str(e), supplier_id=supplier_id, tenant_id=tenant_id)
return None
# ================================================================
# DASHBOARD AND ANALYTICS
# ================================================================
async def get_dashboard_summary(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get suppliers dashboard summary data"""
try:
result = await self.get("suppliers/dashboard/summary", tenant_id=tenant_id)
if result:
logger.info("Retrieved suppliers dashboard summary",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting suppliers dashboard summary",
error=str(e), tenant_id=tenant_id)
return None
async def get_cost_analysis(self, tenant_id: str, start_date: str, end_date: str) -> Optional[Dict[str, Any]]:
"""Get cost analysis across suppliers"""
try:
params = {
"start_date": start_date,
"end_date": end_date
}
result = await self.get("suppliers/analytics/cost-analysis", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved supplier cost analysis",
start_date=start_date, end_date=end_date, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting supplier cost analysis",
error=str(e), tenant_id=tenant_id)
return None
async def get_supplier_reliability_metrics(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get supplier reliability and quality metrics"""
try:
result = await self.get("suppliers/analytics/reliability-metrics", tenant_id=tenant_id)
if result:
logger.info("Retrieved supplier reliability metrics",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting supplier reliability metrics",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# ALERTS AND NOTIFICATIONS
# ================================================================
async def acknowledge_alert(self, tenant_id: str, alert_id: str) -> Optional[Dict[str, Any]]:
"""Acknowledge a supplier-related alert"""
try:
result = await self.post(f"suppliers/alerts/{alert_id}/acknowledge", data={}, tenant_id=tenant_id)
if result:
logger.info("Acknowledged supplier alert",
alert_id=alert_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error acknowledging supplier alert",
error=str(e), alert_id=alert_id, tenant_id=tenant_id)
return None
# ================================================================
# COUNT AND STATISTICS
# ================================================================
async def count_suppliers(self, tenant_id: str) -> int:
"""
Get the count of suppliers for a tenant
Used for subscription limit tracking
Returns:
int: Number of suppliers for the tenant
"""
try:
result = await self.get("suppliers/count", tenant_id=tenant_id)
count = result.get('count', 0) if result else 0
logger.info("Retrieved supplier count from suppliers service",
count=count, tenant_id=tenant_id)
return count
except Exception as e:
logger.error("Error getting supplier count",
error=str(e), tenant_id=tenant_id)
return 0
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if suppliers service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Suppliers service health check failed", error=str(e))
return False
# Factory function for dependency injection
def create_suppliers_client(config: BaseServiceSettings, service_name: str = "unknown") -> SuppliersServiceClient:
"""Create suppliers service client instance"""
return SuppliersServiceClient(config, calling_service_name=service_name)

798
shared/clients/tenant_client.py Executable file
View File

@@ -0,0 +1,798 @@
# shared/clients/tenant_client.py
"""
Tenant Service Client for Inter-Service Communication
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
URL Pattern Architecture (Redesigned):
- Registration endpoints: /api/v1/registration/*
- Tenant subscription endpoints: /api/v1/tenants/{tenant_id}/subscription/*
- Setup intents: /api/v1/setup-intents/*
- Payment customers: /api/v1/payment-customers/*
For more details, see services/tenant/README.md
"""
import structlog
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
logger = structlog.get_logger()
class TenantServiceClient(BaseServiceClient):
"""Client for communicating with the Tenant Service"""
def __init__(self, config: BaseServiceSettings):
super().__init__("tenant", config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# TENANT SETTINGS ENDPOINTS
# ================================================================
async def get_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get all settings for a tenant"""
try:
result = await self.get("settings", tenant_id=tenant_id)
if result:
logger.info("Retrieved all settings from tenant service",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting all settings",
error=str(e), tenant_id=tenant_id)
return None
async def get_category_settings(self, tenant_id: str, category: str) -> Optional[Dict[str, Any]]:
"""Get settings for a specific category"""
try:
result = await self.get(f"settings/{category}", tenant_id=tenant_id)
if result:
logger.info("Retrieved category settings from tenant service",
tenant_id=tenant_id, category=category)
return result
except Exception as e:
logger.error("Error getting category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
async def get_procurement_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get procurement settings for a tenant"""
result = await self.get_category_settings(tenant_id, "procurement")
return result.get('settings', {}) if result else {}
async def get_inventory_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get inventory settings for a tenant"""
result = await self.get_category_settings(tenant_id, "inventory")
return result.get('settings', {}) if result else {}
async def get_production_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get production settings for a tenant"""
result = await self.get_category_settings(tenant_id, "production")
return result.get('settings', {}) if result else {}
async def get_supplier_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get supplier settings for a tenant"""
result = await self.get_category_settings(tenant_id, "supplier")
return result.get('settings', {}) if result else {}
async def get_pos_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get POS settings for a tenant"""
result = await self.get_category_settings(tenant_id, "pos")
return result.get('settings', {}) if result else {}
async def get_order_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get order settings for a tenant"""
result = await self.get_category_settings(tenant_id, "order")
return result.get('settings', {}) if result else {}
async def get_notification_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get notification settings for a tenant"""
result = await self.get_category_settings(tenant_id, "notification")
return result.get('settings', {}) if result else {}
async def update_settings(self, tenant_id: str, settings_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update settings for a tenant"""
try:
result = await self.put("settings", data=settings_data, tenant_id=tenant_id)
if result:
logger.info("Updated tenant settings", tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error updating tenant settings",
error=str(e), tenant_id=tenant_id)
return None
async def update_category_settings(
self,
tenant_id: str,
category: str,
settings_data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Update settings for a specific category"""
try:
result = await self.put(f"settings/{category}", data=settings_data, tenant_id=tenant_id)
if result:
logger.info("Updated category settings",
tenant_id=tenant_id, category=category)
return result
except Exception as e:
logger.error("Error updating category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
async def reset_category_settings(self, tenant_id: str, category: str) -> Optional[Dict[str, Any]]:
"""Reset category settings to default values"""
try:
result = await self.post(f"settings/{category}/reset", data={}, tenant_id=tenant_id)
if result:
logger.info("Reset category settings to defaults",
tenant_id=tenant_id, category=category)
return result
except Exception as e:
logger.error("Error resetting category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
# ================================================================
# TENANT MANAGEMENT
# ================================================================
async def get_tenant(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get tenant details"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}")
if result:
logger.info("Retrieved tenant details", tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting tenant details",
error=str(e), tenant_id=tenant_id)
return None
async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> Optional[list]:
"""Get all active tenants"""
try:
result = await self._make_request(
"GET",
f"tenants?skip={skip}&limit={limit}"
)
if result:
logger.info("Retrieved active tenants from tenant service",
count=len(result) if isinstance(result, list) else 0)
return result if result else []
except Exception as e:
logger.error(f"Error getting active tenants: {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"""
try:
result = await self._make_request("GET", f"tenants/{parent_tenant_id}/children")
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"""
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"""
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"""
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"""
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
# ================================================================
async def health_check(self) -> bool:
"""Check if tenant service is healthy"""
try:
result = await self.get("../health")
return result is not None
except Exception as e:
logger.error(f"Tenant service health check failed: {str(e)}")
return False
# ================================================================
# SUBSCRIPTION STATUS ENDPOINTS (NEW URL PATTERNS)
# ================================================================
async def get_subscription_status(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get subscription status for a tenant"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/status")
if result:
logger.info("Retrieved subscription status from tenant service",
tenant_id=tenant_id, status=result.get('status'))
return result
except Exception as e:
logger.error("Error getting subscription status",
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_details(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed subscription information for a tenant"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/details")
if result:
logger.info("Retrieved subscription details from tenant service",
tenant_id=tenant_id, plan=result.get('plan'))
return result
except Exception as e:
logger.error("Error getting subscription details",
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_tier(self, tenant_id: str) -> Optional[str]:
"""Get subscription tier for a tenant (cached endpoint)"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/tier")
return result.get('tier') if result else None
except Exception as e:
logger.error("Error getting subscription tier",
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_limits(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get subscription limits for a tenant"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/limits")
return result
except Exception as e:
logger.error("Error getting subscription limits",
error=str(e), tenant_id=tenant_id)
return None
async def get_usage_summary(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get usage summary vs limits for a tenant"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/usage")
return result
except Exception as e:
logger.error("Error getting usage summary",
error=str(e), tenant_id=tenant_id)
return None
async def has_feature(self, tenant_id: str, feature: str) -> bool:
"""Check if tenant has access to a specific feature"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/features/{feature}")
return result.get('has_feature', False) if result else False
except Exception as e:
logger.error("Error checking feature access",
error=str(e), tenant_id=tenant_id, feature=feature)
return False
# ================================================================
# QUOTA CHECK ENDPOINTS (NEW URL PATTERNS)
# ================================================================
async def can_add_location(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another location"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/limits/locations")
return result or {"can_add": False, "reason": "Service unavailable"}
except Exception as e:
logger.error("Error checking location limits",
error=str(e), tenant_id=tenant_id)
return {"can_add": False, "reason": str(e)}
async def can_add_product(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another product"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/limits/products")
return result or {"can_add": False, "reason": "Service unavailable"}
except Exception as e:
logger.error("Error checking product limits",
error=str(e), tenant_id=tenant_id)
return {"can_add": False, "reason": str(e)}
async def can_add_user(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another user"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/limits/users")
return result or {"can_add": False, "reason": "Service unavailable"}
except Exception as e:
logger.error("Error checking user limits",
error=str(e), tenant_id=tenant_id)
return {"can_add": False, "reason": str(e)}
async def can_add_recipe(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another recipe"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/limits/recipes")
return result or {"can_add": False, "reason": "Service unavailable"}
except Exception as e:
logger.error("Error checking recipe limits",
error=str(e), tenant_id=tenant_id)
return {"can_add": False, "reason": str(e)}
async def can_add_supplier(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another supplier"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/limits/suppliers")
return result or {"can_add": False, "reason": "Service unavailable"}
except Exception as e:
logger.error("Error checking supplier limits",
error=str(e), tenant_id=tenant_id)
return {"can_add": False, "reason": str(e)}
# ================================================================
# SUBSCRIPTION MANAGEMENT ENDPOINTS (NEW URL PATTERNS)
# ================================================================
async def cancel_subscription(self, tenant_id: str, reason: str = "") -> Dict[str, Any]:
"""Cancel a subscription"""
try:
result = await self._make_request(
"POST",
f"tenants/{tenant_id}/subscription/cancel",
params={"reason": reason}
)
return result or {"success": False, "message": "Cancellation failed"}
except Exception as e:
logger.error("Error cancelling subscription",
error=str(e), tenant_id=tenant_id)
return {"success": False, "message": str(e)}
async def reactivate_subscription(self, tenant_id: str, plan: str = "starter") -> Dict[str, Any]:
"""Reactivate a subscription"""
try:
result = await self._make_request(
"POST",
f"tenants/{tenant_id}/subscription/reactivate",
params={"plan": plan}
)
return result or {"success": False, "message": "Reactivation failed"}
except Exception as e:
logger.error("Error reactivating subscription",
error=str(e), tenant_id=tenant_id)
return {"success": False, "message": str(e)}
async def validate_plan_upgrade(self, tenant_id: str, new_plan: str) -> Dict[str, Any]:
"""Validate plan upgrade eligibility"""
try:
result = await self._make_request(
"GET",
f"tenants/{tenant_id}/subscription/validate-upgrade/{new_plan}"
)
return result or {"can_upgrade": False, "reason": "Validation failed"}
except Exception as e:
logger.error("Error validating plan upgrade",
error=str(e), tenant_id=tenant_id, new_plan=new_plan)
return {"can_upgrade": False, "reason": str(e)}
async def upgrade_subscription_plan(self, tenant_id: str, new_plan: str) -> Dict[str, Any]:
"""Upgrade subscription plan"""
try:
result = await self._make_request(
"POST",
f"tenants/{tenant_id}/subscription/upgrade",
params={"new_plan": new_plan}
)
return result or {"success": False, "message": "Upgrade failed"}
except Exception as e:
logger.error("Error upgrading subscription plan",
error=str(e), tenant_id=tenant_id, new_plan=new_plan)
return {"success": False, "message": str(e)}
# ================================================================
# PAYMENT MANAGEMENT ENDPOINTS (NEW URL PATTERNS)
# ================================================================
async def get_payment_method(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get payment method for a tenant"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/payment-method")
return result
except Exception as e:
logger.error("Error getting payment method",
error=str(e), tenant_id=tenant_id)
return None
async def update_payment_method(self, tenant_id: str, payment_method_id: str) -> Dict[str, Any]:
"""Update payment method for a tenant"""
try:
result = await self._make_request(
"POST",
f"tenants/{tenant_id}/subscription/payment-method",
params={"payment_method_id": payment_method_id}
)
return result or {"success": False, "message": "Update failed"}
except Exception as e:
logger.error("Error updating payment method",
error=str(e), tenant_id=tenant_id)
return {"success": False, "message": str(e)}
async def get_invoices(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get invoices for a tenant"""
try:
result = await self._make_request("GET", f"tenants/{tenant_id}/subscription/invoices")
return result.get('invoices', []) if result else None
except Exception as e:
logger.error("Error getting invoices",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# REGISTRATION FLOW ENDPOINTS (NEW URL PATTERNS)
# ================================================================
async def start_registration_payment_setup(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""Start registration payment setup (SetupIntent-first architecture)"""
try:
logger.info("Starting registration payment setup via tenant service",
email=user_data.get('email'),
plan_id=user_data.get('plan_id'))
result = await self._make_request(
"POST",
"registration/payment-setup",
data=user_data
)
if result and result.get("success"):
logger.info("Registration payment setup completed",
email=user_data.get('email'),
setup_intent_id=result.get('setup_intent_id'))
return result
else:
error_msg = result.get('detail') if result else 'Unknown error'
logger.error("Registration payment setup failed",
email=user_data.get('email'), error=error_msg)
raise Exception(f"Registration payment setup failed: {error_msg}")
except Exception as e:
logger.error("Failed to start registration payment setup",
email=user_data.get('email'), error=str(e))
raise
async def complete_registration(self, setup_intent_id: str, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""Complete registration after 3DS verification"""
try:
logger.info("Completing registration via tenant service",
setup_intent_id=setup_intent_id,
email=user_data.get('email'))
registration_data = {
"setup_intent_id": setup_intent_id,
"user_data": user_data
}
result = await self._make_request(
"POST",
"registration/complete",
data=registration_data
)
if result and result.get("success"):
logger.info("Registration completed successfully",
setup_intent_id=setup_intent_id,
subscription_id=result.get('subscription_id'))
return result
else:
error_msg = result.get('detail') if result else 'Unknown error'
logger.error("Registration completion failed",
setup_intent_id=setup_intent_id, error=error_msg)
raise Exception(f"Registration completion failed: {error_msg}")
except Exception as e:
logger.error("Failed to complete registration",
setup_intent_id=setup_intent_id, error=str(e))
raise
async def get_registration_state(self, state_id: str) -> Optional[Dict[str, Any]]:
"""Get registration state by ID"""
try:
result = await self._make_request("GET", f"registration/state/{state_id}")
return result
except Exception as e:
logger.error("Error getting registration state",
error=str(e), state_id=state_id)
return None
# ================================================================
# SETUP INTENT VERIFICATION (NEW URL PATTERNS)
# ================================================================
async def verify_setup_intent(self, setup_intent_id: str) -> Dict[str, Any]:
"""Verify SetupIntent status"""
try:
logger.info("Verifying SetupIntent via tenant service",
setup_intent_id=setup_intent_id)
result = await self._make_request(
"GET",
f"setup-intents/{setup_intent_id}/verify"
)
if result:
logger.info("SetupIntent verification result",
setup_intent_id=setup_intent_id,
status=result.get('status'))
return result
else:
raise Exception("SetupIntent verification failed: No result returned")
except Exception as e:
logger.error("Failed to verify SetupIntent",
setup_intent_id=setup_intent_id, error=str(e))
raise
async def verify_setup_intent_for_registration(self, setup_intent_id: str) -> Dict[str, Any]:
"""Verify SetupIntent status for registration flow (alias for verify_setup_intent)"""
return await self.verify_setup_intent(setup_intent_id)
# ================================================================
# PAYMENT CUSTOMER MANAGEMENT (NEW URL PATTERNS)
# ================================================================
async def create_payment_customer(
self,
user_data: Dict[str, Any],
payment_method_id: Optional[str] = None
) -> Dict[str, Any]:
"""Create payment customer"""
try:
logger.info("Creating payment customer via tenant service",
email=user_data.get('email'),
payment_method_id=payment_method_id)
request_data = user_data
params = {}
if payment_method_id:
params["payment_method_id"] = payment_method_id
result = await self._make_request(
"POST",
"payment-customers/create",
data=request_data,
params=params if params else None
)
if result and result.get("success"):
logger.info("Payment customer created successfully",
email=user_data.get('email'),
payment_customer_id=result.get('payment_customer_id'))
return result
else:
error_msg = result.get('detail') if result else 'Unknown error'
logger.error("Payment customer creation failed",
email=user_data.get('email'), error=error_msg)
raise Exception(f"Payment customer creation failed: {error_msg}")
except Exception as e:
logger.error("Failed to create payment customer",
email=user_data.get('email'), error=str(e))
raise
# ================================================================
# LEGACY COMPATIBILITY METHODS
# ================================================================
async def create_registration_payment_setup(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""Create registration payment setup via tenant service orchestration"""
return await self.start_registration_payment_setup(user_data)
async def verify_and_complete_registration(
self,
setup_intent_id: str,
user_data: Dict[str, Any]
) -> Dict[str, Any]:
"""Verify SetupIntent and complete registration"""
return await self.complete_registration(setup_intent_id, user_data)
async def create_subscription_for_registration(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
billing_cycle: str = "monthly",
coupon_code: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Create a tenant-independent subscription during user registration"""
try:
logger.info("Creating tenant-independent subscription for registration",
user_id=user_data.get('user_id'),
plan_id=plan_id,
billing_cycle=billing_cycle)
registration_data = {
**user_data,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"billing_cycle": billing_cycle,
"coupon_code": coupon_code
}
setup_result = await self.start_registration_payment_setup(registration_data)
if setup_result and setup_result.get("success"):
return {
"subscription_id": setup_result.get('setup_intent_id'),
"customer_id": setup_result.get('customer_id'),
"status": "pending_verification",
"plan": plan_id,
"billing_cycle": billing_cycle,
"setup_intent_id": setup_result.get('setup_intent_id'),
"client_secret": setup_result.get('client_secret')
}
return None
except Exception as e:
logger.error("Failed to create subscription for registration",
user_id=user_data.get('user_id'), error=str(e))
return None
async def link_subscription_to_tenant(
self,
tenant_id: str,
subscription_id: str,
user_id: str
) -> Optional[Dict[str, Any]]:
"""Link a pending subscription to a tenant"""
try:
logger.info("Linking subscription to tenant",
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
linking_data = {
"subscription_id": subscription_id,
"user_id": user_id
}
result = await self._make_request(
"POST",
f"tenants/{tenant_id}/link-subscription",
data=linking_data
)
if result and result.get("success"):
logger.info("Subscription linked to tenant successfully",
tenant_id=tenant_id,
subscription_id=subscription_id)
return result
else:
logger.error("Subscription linking failed",
tenant_id=tenant_id,
subscription_id=subscription_id,
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to link subscription to tenant",
tenant_id=tenant_id,
subscription_id=subscription_id,
error=str(e))
return None
async def get_user_primary_tenant(self, user_id: str) -> Optional[Dict[str, Any]]:
"""Get the primary tenant for a user"""
try:
logger.info("Getting primary tenant for user",
user_id=user_id)
result = await self._make_request(
"GET",
f"tenants/users/{user_id}/primary-tenant"
)
if result:
logger.info("Primary tenant retrieved successfully",
user_id=user_id,
tenant_id=result.get('tenant_id'))
return result
else:
logger.warning("No primary tenant found for user",
user_id=user_id)
return None
except Exception as e:
logger.error("Failed to get primary tenant for user",
user_id=user_id,
error=str(e))
return None
async def get_user_memberships(self, user_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get all tenant memberships for a user"""
try:
logger.info("Getting tenant memberships for user",
user_id=user_id)
result = await self._make_request(
"GET",
f"tenants/members/user/{user_id}"
)
if result:
logger.info("User memberships retrieved successfully",
user_id=user_id,
membership_count=len(result))
return result
else:
logger.warning("No memberships found for user",
user_id=user_id)
return []
except Exception as e:
logger.error("Failed to get user memberships",
user_id=user_id,
error=str(e))
return None
# Factory function for dependency injection
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:
"""Create tenant service client instance"""
return TenantServiceClient(config)

162
shared/clients/training_client.py Executable file
View File

@@ -0,0 +1,162 @@
# shared/clients/training_client.py
"""
Training Service Client
Handles all API calls to the training service
"""
from typing import Dict, Any, Optional, List
from .base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
class TrainingServiceClient(BaseServiceClient):
"""Client for communicating with the training service"""
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# TRAINING JOBS
# ================================================================
async def create_training_job(
self,
tenant_id: str,
include_weather: bool = True,
include_traffic: bool = False,
min_data_points: int = 30,
**kwargs
) -> Optional[Dict[str, Any]]:
"""Create a new training job"""
data = {
"include_weather": include_weather,
"include_traffic": include_traffic,
"min_data_points": min_data_points,
**kwargs
}
return await self.post("training/jobs", data=data, tenant_id=tenant_id)
async def get_training_job(self, tenant_id: str, job_id: str) -> Optional[Dict[str, Any]]:
"""Get training job details"""
return await self.get(f"training/jobs/{job_id}/status", tenant_id=tenant_id)
async def list_training_jobs(
self,
tenant_id: str,
status: Optional[str] = None,
limit: int = 50
) -> Optional[List[Dict[str, Any]]]:
"""List training jobs for a tenant"""
params = {"limit": limit}
if status:
params["status"] = status
result = await self.get("training/jobs", tenant_id=tenant_id, params=params)
return result.get("jobs", []) if result else None
async def cancel_training_job(self, tenant_id: str, job_id: str) -> Optional[Dict[str, Any]]:
"""Cancel a training job"""
return await self.delete(f"training/jobs/{job_id}", tenant_id=tenant_id)
# ================================================================
# MODELS
# ================================================================
async def get_model(self, tenant_id: str, model_id: str) -> Optional[Dict[str, Any]]:
"""Get model details"""
return await self.get(f"training/models/{model_id}", tenant_id=tenant_id)
async def list_models(
self,
tenant_id: str,
status: Optional[str] = None,
model_type: Optional[str] = None,
limit: int = 50
) -> Optional[List[Dict[str, Any]]]:
"""List models for a tenant"""
params = {"limit": limit}
if status:
params["status"] = status
if model_type:
params["model_type"] = model_type
result = await self.get("training/models", tenant_id=tenant_id, params=params)
return result.get("models", []) if result else None
async def get_active_model_for_product(
self,
tenant_id: str,
inventory_product_id: str
) -> Optional[Dict[str, Any]]:
"""
Get the active model for a specific product by inventory product ID
This is the preferred method since models are stored per product.
"""
result = await self.get(f"training/models/{inventory_product_id}/active", tenant_id=tenant_id)
return result
async def deploy_model(self, tenant_id: str, model_id: str) -> Optional[Dict[str, Any]]:
"""Deploy a trained model"""
return await self.post(f"training/models/{model_id}/deploy", data={}, tenant_id=tenant_id)
async def delete_model(self, tenant_id: str, model_id: str) -> Optional[Dict[str, Any]]:
"""Delete a model"""
return await self.delete(f"training/models/{model_id}", tenant_id=tenant_id)
# ================================================================
# MODEL METRICS & PERFORMANCE
# ================================================================
async def get_model_metrics(self, tenant_id: str, model_id: str) -> Optional[Dict[str, Any]]:
"""Get model performance metrics"""
return await self.get(f"training/models/{model_id}/metrics", tenant_id=tenant_id)
async def get_model_predictions(
self,
tenant_id: str,
model_id: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None
) -> Optional[List[Dict[str, Any]]]:
"""Get model predictions for evaluation"""
params = {}
if start_date:
params["start_date"] = start_date
if end_date:
params["end_date"] = end_date
result = await self.get(f"training/models/{model_id}/predictions", tenant_id=tenant_id, params=params)
return result.get("predictions", []) if result else None
async def trigger_retrain(
self,
tenant_id: str,
inventory_product_id: str,
reason: str = 'manual',
metadata: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
"""
Trigger model retraining for a specific product.
Used by orchestrator when forecast accuracy degrades.
Args:
tenant_id: Tenant UUID
inventory_product_id: Product UUID to retrain model for
reason: Reason for retraining (accuracy_degradation, manual, scheduled, etc.)
metadata: Optional metadata (e.g., previous_mape, validation_date, etc.)
Returns:
Training job details or None if failed
"""
data = {
"inventory_product_id": inventory_product_id,
"reason": reason,
"metadata": metadata or {},
"include_weather": True,
"include_traffic": False,
"min_data_points": 30
}
return await self.post("training/models/retrain", data=data, tenant_id=tenant_id)