# shared/clients/forecast_client.py """ Forecast Service Client - Updated for refactored backend structure Handles all API calls to the forecasting service Backend structure: - ATOMIC: /forecasting/forecasts (CRUD) - BUSINESS: /forecasting/operations/* (single, multi-day, batch, etc.) - ANALYTICS: /forecasting/analytics/* (predictions-performance) """ from typing import Dict, Any, Optional, List from datetime import date from .base_service_client import BaseServiceClient from shared.config.base import BaseServiceSettings 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) # ================================================================ # 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 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