Spaces:
Running
Running
| """ | |
| 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 |