""" Inventory forecasting algorithms for Wildberries Analytics Dashboard Implements multiple methods for predicting days until stockout """ import pandas as pd import numpy as np from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple, Any import logging logger = logging.getLogger(__name__) class InventoryForecaster: """ Inventory forecasting engine with multiple algorithms """ def __init__(self, confidence_level: float = 0.95): """ Initialize forecaster Args: confidence_level: Confidence level for safety stock calculations (0.0-1.0) """ self.confidence_level = confidence_level self.z_score = self._get_z_score(confidence_level) def _get_z_score(self, confidence_level: float) -> float: """Get Z-score for given confidence level""" z_scores = { 0.90: 1.28, 0.95: 1.65, 0.99: 2.33 } # Find closest confidence level closest = min(z_scores.keys(), key=lambda x: abs(x - confidence_level)) return z_scores[closest] def simple_division_method(self, current_stock: float, avg_daily_sales: float) -> float: """ Simple division method: current stock / average daily sales Args: current_stock: Current inventory level avg_daily_sales: Average daily sales rate Returns: Days until stockout """ if avg_daily_sales <= 0: return float('inf') # Never runs out if no sales if current_stock <= 0: return 0 # Already out of stock return current_stock / avg_daily_sales def safety_stock_method(self, current_stock: float, avg_daily_sales: float, max_daily_sales: float, avg_lead_time: int = 7, max_lead_time: int = 14) -> float: """ Safety stock method accounting for demand and lead time variability Args: current_stock: Current inventory level avg_daily_sales: Average daily sales rate max_daily_sales: Maximum observed daily sales avg_lead_time: Average supplier lead time in days max_lead_time: Maximum supplier lead time in days Returns: Days until stockout considering safety buffer """ if avg_daily_sales <= 0: return float('inf') if current_stock <= 0: return 0 # Calculate safety stock # Safety stock = (Max daily sales × Max lead time) - (Avg daily sales × Avg lead time) safety_stock = (max_daily_sales * max_lead_time) - (avg_daily_sales * avg_lead_time) safety_stock = max(0, safety_stock) # Ensure non-negative # Effective stock after accounting for safety buffer effective_stock = max(0, current_stock - safety_stock) if effective_stock <= 0: return 0 # Below safety stock threshold return effective_stock / avg_daily_sales def weighted_average_method(self, current_stock: float, sales_data: pd.DataFrame, recent_weight: float = 0.5, medium_weight: float = 0.3, old_weight: float = 0.2) -> float: """ Weighted average method giving more weight to recent sales Args: current_stock: Current inventory level sales_data: DataFrame with sales data including 'sale_date' and 'quantity' recent_weight: Weight for most recent week medium_weight: Weight for second recent week old_weight: Weight for older data Returns: Days until stockout based on weighted average """ if sales_data.empty or current_stock <= 0: return 0 if current_stock <= 0 else float('inf') # Ensure we have required columns if 'sale_date' not in sales_data.columns or 'quantity' not in sales_data.columns: logger.warning("Missing required columns in sales data") return self.simple_division_method(current_stock, sales_data['quantity'].mean() if 'quantity' in sales_data.columns else 1) try: # Convert sale_date to datetime if not already sales_data = sales_data.copy() if not pd.api.types.is_datetime64_any_dtype(sales_data['sale_date']): sales_data['sale_date'] = pd.to_datetime(sales_data['sale_date']) # Sort by date sales_data = sales_data.sort_values('sale_date') # Calculate periods now = datetime.now() week_1_start = now - timedelta(days=7) week_2_start = now - timedelta(days=14) # Group sales by periods recent_sales = sales_data[sales_data['sale_date'] >= week_1_start]['quantity'].sum() medium_sales = sales_data[ (sales_data['sale_date'] >= week_2_start) & (sales_data['sale_date'] < week_1_start) ]['quantity'].sum() old_sales = sales_data[sales_data['sale_date'] < week_2_start]['quantity'].sum() # Calculate daily averages for each period recent_daily = recent_sales / 7 if recent_sales > 0 else 0 medium_daily = medium_sales / 7 if medium_sales > 0 else 0 old_days = len(sales_data[sales_data['sale_date'] < week_2_start]) old_daily = old_sales / max(old_days, 1) if old_sales > 0 and old_days > 0 else 0 # Calculate weighted average weighted_daily_sales = ( recent_daily * recent_weight + medium_daily * medium_weight + old_daily * old_weight ) if weighted_daily_sales <= 0: return float('inf') return current_stock / weighted_daily_sales except Exception as e: logger.error(f"Error in weighted average calculation: {str(e)}") # Fallback to simple method avg_daily = sales_data['quantity'].sum() / len(sales_data) if len(sales_data) > 0 else 1 return self.simple_division_method(current_stock, avg_daily) def seasonal_adjustment_method(self, current_stock: float, avg_daily_sales: float, seasonal_factor: float = 1.0, trend_factor: float = 1.0) -> float: """ Seasonal adjustment method accounting for seasonal patterns Args: current_stock: Current inventory level avg_daily_sales: Average daily sales rate seasonal_factor: Seasonal adjustment factor (1.0 = no adjustment) trend_factor: Trend adjustment factor (1.0 = no trend) Returns: Days until stockout with seasonal adjustment """ if avg_daily_sales <= 0: return float('inf') if current_stock <= 0: return 0 # Adjust demand for seasonal and trend factors adjusted_daily_sales = avg_daily_sales * seasonal_factor * trend_factor adjusted_daily_sales = max(0.1, adjusted_daily_sales) # Minimum threshold return current_stock / adjusted_daily_sales def calculate_reorder_point(self, avg_daily_sales: float, lead_time_days: int = 7, safety_stock: float = None) -> float: """ Calculate reorder point for inventory management Args: avg_daily_sales: Average daily sales rate lead_time_days: Lead time in days safety_stock: Safety stock level (if None, calculated automatically) Returns: Recommended reorder point """ if avg_daily_sales <= 0: return 0 # Calculate basic reorder point reorder_point = avg_daily_sales * lead_time_days # Add safety stock if provided if safety_stock is not None: reorder_point += safety_stock else: # Calculate automatic safety stock (2 weeks of average sales) auto_safety_stock = avg_daily_sales * 14 reorder_point += auto_safety_stock return reorder_point def calculate_economic_order_quantity(self, annual_demand: float, ordering_cost: float, holding_cost_per_unit: float) -> Tuple[float, float]: """ Calculate Economic Order Quantity (EOQ) Args: annual_demand: Annual demand in units ordering_cost: Cost per order holding_cost_per_unit: Annual holding cost per unit Returns: Tuple of (EOQ, Total Cost) """ if annual_demand <= 0 or ordering_cost <= 0 or holding_cost_per_unit <= 0: return 0, 0 # EOQ formula: sqrt(2 * D * S / H) eoq = np.sqrt((2 * annual_demand * ordering_cost) / holding_cost_per_unit) # Total cost calculation ordering_cost_total = (annual_demand / eoq) * ordering_cost holding_cost_total = (eoq / 2) * holding_cost_per_unit total_cost = ordering_cost_total + holding_cost_total return eoq, total_cost def batch_forecast(self, inventory_data: pd.DataFrame, sales_data: pd.DataFrame, method: str = "simple") -> pd.DataFrame: """ Perform batch forecasting for multiple products Args: inventory_data: DataFrame with inventory levels sales_data: DataFrame with sales history method: Forecasting method to use Returns: DataFrame with forecasting results """ results = [] for _, product in inventory_data.iterrows(): product_id = product.get('product_id') current_stock = product.get('current_stock', 0) product_name = product.get('product_name', f'Product {product_id}') # Filter sales data for this product product_sales = sales_data[sales_data['product_id'] == product_id] if 'product_id' in sales_data.columns else pd.DataFrame() # Calculate forecast based on method try: if method == "simple": avg_daily = product_sales['quantity'].mean() if not product_sales.empty and 'quantity' in product_sales.columns else 1 days_left = self.simple_division_method(current_stock, avg_daily) elif method == "safety_stock": if not product_sales.empty and 'quantity' in product_sales.columns: avg_daily = product_sales['quantity'].mean() max_daily = product_sales['quantity'].max() days_left = self.safety_stock_method(current_stock, avg_daily, max_daily) else: days_left = self.simple_division_method(current_stock, 1) elif method == "weighted": if not product_sales.empty: days_left = self.weighted_average_method(current_stock, product_sales) else: days_left = self.simple_division_method(current_stock, 1) elif method == "seasonal": avg_daily = product_sales['quantity'].mean() if not product_sales.empty and 'quantity' in product_sales.columns else 1 # For demo, use seasonal factor based on current month current_month = datetime.now().month seasonal_factors = {12: 1.5, 1: 1.3, 2: 0.8, 3: 0.9, 4: 1.0, 5: 1.1, 6: 1.2, 7: 1.1, 8: 1.0, 9: 0.9, 10: 1.1, 11: 1.3} seasonal_factor = seasonal_factors.get(current_month, 1.0) days_left = self.seasonal_adjustment_method(current_stock, avg_daily, seasonal_factor) else: # Default to simple method avg_daily = product_sales['quantity'].mean() if not product_sales.empty and 'quantity' in product_sales.columns else 1 days_left = self.simple_division_method(current_stock, avg_daily) # Categorize risk level if days_left < 7: risk_level = "🔴 Critical" risk_score = 3 elif days_left < 14: risk_level = "🟡 Warning" risk_score = 2 else: risk_level = "🟢 Safe" risk_score = 1 # Calculate recommended reorder point avg_daily = product_sales['quantity'].mean() if not product_sales.empty and 'quantity' in product_sales.columns else 1 reorder_point = self.calculate_reorder_point(avg_daily) results.append({ 'product_id': product_id, 'product_name': product_name, 'current_stock': current_stock, 'days_until_stockout': round(days_left, 1), 'risk_level': risk_level, 'risk_score': risk_score, 'recommended_reorder_point': round(reorder_point, 0), 'method_used': method, 'forecast_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') }) except Exception as e: logger.error(f"Error forecasting for product {product_id}: {str(e)}") results.append({ 'product_id': product_id, 'product_name': product_name, 'current_stock': current_stock, 'days_until_stockout': 0, 'risk_level': "❌ Error", 'risk_score': 4, 'recommended_reorder_point': 0, 'method_used': method, 'forecast_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') }) return pd.DataFrame(results) def get_method_description(self, method: str) -> str: """Get description of forecasting method""" descriptions = { "simple": "Простое деление: текущий запас ÷ средние дневные продажи", "safety_stock": "С учетом страхового запаса: учитывает колебания спроса и времени поставки", "weighted": "Взвешенное среднее: больший вес для недавних продаж", "seasonal": "С сезонной корректировкой: учитывает сезонные колебания спроса" } return descriptions.get(method, "Неизвестный метод") def get_recommendations(self, forecast_results: pd.DataFrame) -> Dict[str, Any]: """Generate recommendations based on forecast results""" if forecast_results.empty: return {"message": "Нет данных для анализа"} critical_count = len(forecast_results[forecast_results['risk_score'] == 3]) warning_count = len(forecast_results[forecast_results['risk_score'] == 2]) safe_count = len(forecast_results[forecast_results['risk_score'] == 1]) recommendations = { "summary": { "total_products": len(forecast_results), "critical_items": critical_count, "warning_items": warning_count, "safe_items": safe_count }, "actions": [] } if critical_count > 0: critical_products = forecast_results[forecast_results['risk_score'] == 3]['product_name'].tolist() recommendations["actions"].append({ "priority": "Высокий", "action": f"Срочно заказать товары: {', '.join(critical_products[:3])}{'...' if len(critical_products) > 3 else ''}", "count": critical_count }) if warning_count > 0: recommendations["actions"].append({ "priority": "Средний", "action": f"Подготовить заказ для {warning_count} товаров в ближайшие дни", "count": warning_count }) if safe_count == len(forecast_results): recommendations["actions"].append({ "priority": "Низкий", "action": "Все товары имеют достаточный запас", "count": safe_count }) return recommendations