756 lines
30 KiB
Python
756 lines
30 KiB
Python
|
|
"""
|
|||
|
|
Safety Stock Optimizer
|
|||
|
|
Replaces hardcoded 95% service level with learned optimal safety stock levels
|
|||
|
|
Optimizes based on product characteristics, demand variability, and cost trade-offs
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import pandas as pd
|
|||
|
|
import numpy as np
|
|||
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|||
|
|
import structlog
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from scipy import stats
|
|||
|
|
from scipy.optimize import minimize_scalar
|
|||
|
|
import warnings
|
|||
|
|
warnings.filterwarnings('ignore')
|
|||
|
|
|
|||
|
|
logger = structlog.get_logger()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SafetyStockOptimizer:
|
|||
|
|
"""
|
|||
|
|
Optimizes safety stock levels for inventory management.
|
|||
|
|
|
|||
|
|
Current problem: Hardcoded 95% service level for all products
|
|||
|
|
Solution: Learn optimal service levels based on:
|
|||
|
|
- Product characteristics (shelf life, criticality)
|
|||
|
|
- Demand variability (coefficient of variation)
|
|||
|
|
- Cost trade-offs (holding cost vs stockout cost)
|
|||
|
|
- Historical stockout patterns
|
|||
|
|
- Supplier reliability
|
|||
|
|
|
|||
|
|
Approaches:
|
|||
|
|
1. Statistical approach: Based on demand variability and lead time
|
|||
|
|
2. Cost-based optimization: Minimize total cost (holding + stockout)
|
|||
|
|
3. Service level optimization: Product-specific target service levels
|
|||
|
|
4. Dynamic adjustment: Seasonality and trend awareness
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
self.optimal_stocks = {}
|
|||
|
|
self.learned_service_levels = {}
|
|||
|
|
|
|||
|
|
async def optimize_safety_stock(
|
|||
|
|
self,
|
|||
|
|
tenant_id: str,
|
|||
|
|
inventory_product_id: str,
|
|||
|
|
demand_history: pd.DataFrame,
|
|||
|
|
product_characteristics: Dict[str, Any],
|
|||
|
|
cost_parameters: Optional[Dict[str, float]] = None,
|
|||
|
|
supplier_reliability: Optional[float] = None,
|
|||
|
|
min_history_days: int = 90
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Calculate optimal safety stock for a product.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
tenant_id: Tenant identifier
|
|||
|
|
inventory_product_id: Product identifier
|
|||
|
|
demand_history: Historical demand data with columns:
|
|||
|
|
- date
|
|||
|
|
- demand_quantity
|
|||
|
|
- stockout (bool, optional)
|
|||
|
|
- lead_time_days (optional)
|
|||
|
|
product_characteristics: Product properties:
|
|||
|
|
- shelf_life_days: int
|
|||
|
|
- criticality: str (high, medium, low)
|
|||
|
|
- unit_cost: float
|
|||
|
|
- avg_daily_demand: float
|
|||
|
|
cost_parameters: Optional cost params:
|
|||
|
|
- holding_cost_per_unit_per_day: float
|
|||
|
|
- stockout_cost_per_unit: float
|
|||
|
|
supplier_reliability: Supplier on-time rate (0-1)
|
|||
|
|
min_history_days: Minimum days of history required
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dictionary with optimal safety stock and insights
|
|||
|
|
"""
|
|||
|
|
logger.info(
|
|||
|
|
"Optimizing safety stock",
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
inventory_product_id=inventory_product_id,
|
|||
|
|
history_days=len(demand_history)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Validate input
|
|||
|
|
if len(demand_history) < min_history_days:
|
|||
|
|
logger.warning(
|
|||
|
|
"Insufficient demand history",
|
|||
|
|
inventory_product_id=inventory_product_id,
|
|||
|
|
days=len(demand_history),
|
|||
|
|
required=min_history_days
|
|||
|
|
)
|
|||
|
|
return self._insufficient_data_response(
|
|||
|
|
tenant_id, inventory_product_id, product_characteristics
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Calculate demand statistics
|
|||
|
|
demand_stats = self._calculate_demand_statistics(demand_history)
|
|||
|
|
|
|||
|
|
# Calculate optimal safety stock using multiple methods
|
|||
|
|
statistical_result = self._calculate_statistical_safety_stock(
|
|||
|
|
demand_stats,
|
|||
|
|
product_characteristics,
|
|||
|
|
supplier_reliability
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Cost-based optimization if cost parameters provided
|
|||
|
|
if cost_parameters:
|
|||
|
|
cost_based_result = self._calculate_cost_optimal_safety_stock(
|
|||
|
|
demand_stats,
|
|||
|
|
product_characteristics,
|
|||
|
|
cost_parameters,
|
|||
|
|
demand_history
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
cost_based_result = None
|
|||
|
|
|
|||
|
|
# Service level optimization
|
|||
|
|
service_level_result = self._calculate_service_level_optimal_stock(
|
|||
|
|
demand_stats,
|
|||
|
|
product_characteristics,
|
|||
|
|
demand_history
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Combine methods and select optimal
|
|||
|
|
optimal_result = self._select_optimal_safety_stock(
|
|||
|
|
statistical_result,
|
|||
|
|
cost_based_result,
|
|||
|
|
service_level_result,
|
|||
|
|
product_characteristics
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Compare with current hardcoded approach (95% service level)
|
|||
|
|
hardcoded_result = self._calculate_hardcoded_safety_stock(
|
|||
|
|
demand_stats,
|
|||
|
|
service_level=0.95
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
comparison = self._compare_with_hardcoded(
|
|||
|
|
optimal_result,
|
|||
|
|
hardcoded_result,
|
|||
|
|
cost_parameters
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Generate insights
|
|||
|
|
insights = self._generate_safety_stock_insights(
|
|||
|
|
tenant_id,
|
|||
|
|
inventory_product_id,
|
|||
|
|
optimal_result,
|
|||
|
|
hardcoded_result,
|
|||
|
|
comparison,
|
|||
|
|
demand_stats,
|
|||
|
|
product_characteristics
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Store optimal stock
|
|||
|
|
self.optimal_stocks[inventory_product_id] = optimal_result['safety_stock']
|
|||
|
|
self.learned_service_levels[inventory_product_id] = optimal_result['service_level']
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
"Safety stock optimization complete",
|
|||
|
|
inventory_product_id=inventory_product_id,
|
|||
|
|
optimal_stock=optimal_result['safety_stock'],
|
|||
|
|
optimal_service_level=optimal_result['service_level'],
|
|||
|
|
improvement_vs_hardcoded=comparison.get('cost_savings_pct', 0)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'tenant_id': tenant_id,
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'optimized_at': datetime.utcnow().isoformat(),
|
|||
|
|
'history_days': len(demand_history),
|
|||
|
|
'demand_stats': demand_stats,
|
|||
|
|
'optimal_result': optimal_result,
|
|||
|
|
'hardcoded_result': hardcoded_result,
|
|||
|
|
'comparison': comparison,
|
|||
|
|
'insights': insights
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _calculate_demand_statistics(
|
|||
|
|
self,
|
|||
|
|
demand_history: pd.DataFrame
|
|||
|
|
) -> Dict[str, float]:
|
|||
|
|
"""
|
|||
|
|
Calculate comprehensive demand statistics.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
demand_history: Historical demand data
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dictionary of demand statistics
|
|||
|
|
"""
|
|||
|
|
# Ensure date column
|
|||
|
|
if 'date' not in demand_history.columns:
|
|||
|
|
demand_history = demand_history.copy()
|
|||
|
|
demand_history['date'] = pd.to_datetime(demand_history.index)
|
|||
|
|
|
|||
|
|
demand_history['date'] = pd.to_datetime(demand_history['date'])
|
|||
|
|
|
|||
|
|
# Basic statistics
|
|||
|
|
mean_demand = demand_history['demand_quantity'].mean()
|
|||
|
|
std_demand = demand_history['demand_quantity'].std()
|
|||
|
|
cv_demand = std_demand / mean_demand if mean_demand > 0 else 0
|
|||
|
|
|
|||
|
|
# Lead time statistics (if available)
|
|||
|
|
if 'lead_time_days' in demand_history.columns:
|
|||
|
|
mean_lead_time = demand_history['lead_time_days'].mean()
|
|||
|
|
std_lead_time = demand_history['lead_time_days'].std()
|
|||
|
|
else:
|
|||
|
|
mean_lead_time = 3.0 # Default assumption
|
|||
|
|
std_lead_time = 0.5
|
|||
|
|
|
|||
|
|
# Stockout rate (if available)
|
|||
|
|
if 'stockout' in demand_history.columns:
|
|||
|
|
stockout_rate = demand_history['stockout'].mean()
|
|||
|
|
stockout_frequency = demand_history['stockout'].sum()
|
|||
|
|
else:
|
|||
|
|
stockout_rate = 0.05 # Assume 5% if not tracked
|
|||
|
|
stockout_frequency = 0
|
|||
|
|
|
|||
|
|
# Demand distribution characteristics
|
|||
|
|
skewness = demand_history['demand_quantity'].skew()
|
|||
|
|
kurtosis = demand_history['demand_quantity'].kurtosis()
|
|||
|
|
|
|||
|
|
# Recent trend (last 30 days vs overall)
|
|||
|
|
if len(demand_history) >= 60:
|
|||
|
|
recent_mean = demand_history.tail(30)['demand_quantity'].mean()
|
|||
|
|
trend = (recent_mean - mean_demand) / mean_demand if mean_demand > 0 else 0
|
|||
|
|
else:
|
|||
|
|
trend = 0
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'mean_demand': float(mean_demand),
|
|||
|
|
'std_demand': float(std_demand),
|
|||
|
|
'cv_demand': float(cv_demand),
|
|||
|
|
'min_demand': float(demand_history['demand_quantity'].min()),
|
|||
|
|
'max_demand': float(demand_history['demand_quantity'].max()),
|
|||
|
|
'mean_lead_time': float(mean_lead_time),
|
|||
|
|
'std_lead_time': float(std_lead_time),
|
|||
|
|
'stockout_rate': float(stockout_rate),
|
|||
|
|
'stockout_frequency': int(stockout_frequency),
|
|||
|
|
'skewness': float(skewness),
|
|||
|
|
'kurtosis': float(kurtosis),
|
|||
|
|
'trend': float(trend),
|
|||
|
|
'data_points': int(len(demand_history))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _calculate_statistical_safety_stock(
|
|||
|
|
self,
|
|||
|
|
demand_stats: Dict[str, float],
|
|||
|
|
product_characteristics: Dict[str, Any],
|
|||
|
|
supplier_reliability: Optional[float] = None
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Calculate safety stock using statistical approach (Classic formula).
|
|||
|
|
|
|||
|
|
Formula: SS = Z * sqrt(LT * σ_d² + d_avg² * σ_LT²)
|
|||
|
|
Where:
|
|||
|
|
- Z: Z-score for desired service level
|
|||
|
|
- LT: Mean lead time
|
|||
|
|
- σ_d: Standard deviation of demand
|
|||
|
|
- d_avg: Average demand
|
|||
|
|
- σ_LT: Standard deviation of lead time
|
|||
|
|
"""
|
|||
|
|
# Determine target service level based on product criticality
|
|||
|
|
criticality = product_characteristics.get('criticality', 'medium').lower()
|
|||
|
|
|
|||
|
|
if criticality == 'high':
|
|||
|
|
target_service_level = 0.98 # 98% for critical products
|
|||
|
|
elif criticality == 'medium':
|
|||
|
|
target_service_level = 0.95 # 95% for medium
|
|||
|
|
else:
|
|||
|
|
target_service_level = 0.90 # 90% for low criticality
|
|||
|
|
|
|||
|
|
# Adjust for supplier reliability
|
|||
|
|
if supplier_reliability is not None and supplier_reliability < 0.9:
|
|||
|
|
# Less reliable suppliers need higher safety stock
|
|||
|
|
target_service_level = min(0.99, target_service_level + 0.03)
|
|||
|
|
|
|||
|
|
# Calculate Z-score for target service level
|
|||
|
|
z_score = stats.norm.ppf(target_service_level)
|
|||
|
|
|
|||
|
|
# Calculate safety stock
|
|||
|
|
mean_demand = demand_stats['mean_demand']
|
|||
|
|
std_demand = demand_stats['std_demand']
|
|||
|
|
mean_lead_time = demand_stats['mean_lead_time']
|
|||
|
|
std_lead_time = demand_stats['std_lead_time']
|
|||
|
|
|
|||
|
|
# Safety stock formula
|
|||
|
|
variance_component = (
|
|||
|
|
mean_lead_time * (std_demand ** 2) +
|
|||
|
|
(mean_demand ** 2) * (std_lead_time ** 2)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
safety_stock = z_score * np.sqrt(variance_component)
|
|||
|
|
|
|||
|
|
# Ensure non-negative
|
|||
|
|
safety_stock = max(0, safety_stock)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'method': 'statistical',
|
|||
|
|
'safety_stock': round(safety_stock, 2),
|
|||
|
|
'service_level': target_service_level,
|
|||
|
|
'z_score': round(z_score, 2),
|
|||
|
|
'rationale': f'Based on {target_service_level*100:.0f}% service level for {criticality} criticality product'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _calculate_cost_optimal_safety_stock(
|
|||
|
|
self,
|
|||
|
|
demand_stats: Dict[str, float],
|
|||
|
|
product_characteristics: Dict[str, Any],
|
|||
|
|
cost_parameters: Dict[str, float],
|
|||
|
|
demand_history: pd.DataFrame
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Calculate safety stock that minimizes total cost (holding + stockout).
|
|||
|
|
|
|||
|
|
Total Cost = (Holding Cost × Safety Stock) + (Stockout Cost × Stockout Frequency)
|
|||
|
|
"""
|
|||
|
|
holding_cost = cost_parameters.get('holding_cost_per_unit_per_day', 0.01)
|
|||
|
|
stockout_cost = cost_parameters.get('stockout_cost_per_unit', 10.0)
|
|||
|
|
|
|||
|
|
mean_demand = demand_stats['mean_demand']
|
|||
|
|
std_demand = demand_stats['std_demand']
|
|||
|
|
mean_lead_time = demand_stats['mean_lead_time']
|
|||
|
|
|
|||
|
|
def total_cost(safety_stock):
|
|||
|
|
"""Calculate total cost for given safety stock level."""
|
|||
|
|
# Holding cost (annual)
|
|||
|
|
annual_holding_cost = holding_cost * safety_stock * 365
|
|||
|
|
|
|||
|
|
# Stockout probability and expected stockouts
|
|||
|
|
# Demand during lead time follows normal distribution
|
|||
|
|
demand_during_lt_mean = mean_demand * mean_lead_time
|
|||
|
|
demand_during_lt_std = std_demand * np.sqrt(mean_lead_time)
|
|||
|
|
|
|||
|
|
# Service level achieved with this safety stock
|
|||
|
|
if demand_during_lt_std > 0:
|
|||
|
|
z_score = (safety_stock) / demand_during_lt_std
|
|||
|
|
service_level = stats.norm.cdf(z_score)
|
|||
|
|
else:
|
|||
|
|
service_level = 0.99
|
|||
|
|
|
|||
|
|
# Stockout probability
|
|||
|
|
stockout_prob = 1 - service_level
|
|||
|
|
|
|||
|
|
# Expected annual stockouts (simplified)
|
|||
|
|
orders_per_year = 365 / mean_lead_time
|
|||
|
|
expected_stockouts = stockout_prob * orders_per_year * mean_demand
|
|||
|
|
|
|||
|
|
# Stockout cost (annual)
|
|||
|
|
annual_stockout_cost = expected_stockouts * stockout_cost
|
|||
|
|
|
|||
|
|
return annual_holding_cost + annual_stockout_cost
|
|||
|
|
|
|||
|
|
# Optimize to find minimum total cost
|
|||
|
|
# Search range: 0 to 5 * mean demand during lead time
|
|||
|
|
max_search = 5 * mean_demand * mean_lead_time
|
|||
|
|
|
|||
|
|
result = minimize_scalar(
|
|||
|
|
total_cost,
|
|||
|
|
bounds=(0, max_search),
|
|||
|
|
method='bounded'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
optimal_safety_stock = result.x
|
|||
|
|
optimal_cost = result.fun
|
|||
|
|
|
|||
|
|
# Calculate achieved service level
|
|||
|
|
demand_during_lt_std = std_demand * np.sqrt(mean_lead_time)
|
|||
|
|
if demand_during_lt_std > 0:
|
|||
|
|
z_score = optimal_safety_stock / demand_during_lt_std
|
|||
|
|
achieved_service_level = stats.norm.cdf(z_score)
|
|||
|
|
else:
|
|||
|
|
achieved_service_level = 0.99
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'method': 'cost_optimization',
|
|||
|
|
'safety_stock': round(optimal_safety_stock, 2),
|
|||
|
|
'service_level': round(achieved_service_level, 4),
|
|||
|
|
'annual_total_cost': round(optimal_cost, 2),
|
|||
|
|
'rationale': f'Minimizes total cost (holding + stockout): €{optimal_cost:.2f}/year'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _calculate_service_level_optimal_stock(
|
|||
|
|
self,
|
|||
|
|
demand_stats: Dict[str, float],
|
|||
|
|
product_characteristics: Dict[str, Any],
|
|||
|
|
demand_history: pd.DataFrame
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Calculate safety stock based on empirical service level optimization.
|
|||
|
|
|
|||
|
|
Uses historical stockout data to find optimal service level.
|
|||
|
|
"""
|
|||
|
|
# If we have stockout history, learn from it
|
|||
|
|
if 'stockout' in demand_history.columns and demand_history['stockout'].sum() > 0:
|
|||
|
|
current_stockout_rate = demand_stats['stockout_rate']
|
|||
|
|
|
|||
|
|
# Target: Reduce stockouts by 50% or achieve 95%, whichever is higher
|
|||
|
|
target_stockout_rate = min(current_stockout_rate * 0.5, 0.05)
|
|||
|
|
target_service_level = 1 - target_stockout_rate
|
|||
|
|
|
|||
|
|
else:
|
|||
|
|
# No stockout data, use criticality-based default
|
|||
|
|
criticality = product_characteristics.get('criticality', 'medium').lower()
|
|||
|
|
target_service_level = {
|
|||
|
|
'high': 0.98,
|
|||
|
|
'medium': 0.95,
|
|||
|
|
'low': 0.90
|
|||
|
|
}.get(criticality, 0.95)
|
|||
|
|
|
|||
|
|
# Calculate safety stock for target service level
|
|||
|
|
z_score = stats.norm.ppf(target_service_level)
|
|||
|
|
mean_demand = demand_stats['mean_demand']
|
|||
|
|
std_demand = demand_stats['std_demand']
|
|||
|
|
mean_lead_time = demand_stats['mean_lead_time']
|
|||
|
|
|
|||
|
|
safety_stock = z_score * std_demand * np.sqrt(mean_lead_time)
|
|||
|
|
safety_stock = max(0, safety_stock)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'method': 'service_level_optimization',
|
|||
|
|
'safety_stock': round(safety_stock, 2),
|
|||
|
|
'service_level': target_service_level,
|
|||
|
|
'rationale': f'Achieves {target_service_level*100:.0f}% service level based on historical performance'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _select_optimal_safety_stock(
|
|||
|
|
self,
|
|||
|
|
statistical_result: Dict[str, Any],
|
|||
|
|
cost_based_result: Optional[Dict[str, Any]],
|
|||
|
|
service_level_result: Dict[str, Any],
|
|||
|
|
product_characteristics: Dict[str, Any]
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Select optimal safety stock from multiple methods.
|
|||
|
|
|
|||
|
|
Priority:
|
|||
|
|
1. Cost-based if available and product value is high
|
|||
|
|
2. Statistical for general case
|
|||
|
|
3. Service level as validation
|
|||
|
|
"""
|
|||
|
|
# If cost data available and product is valuable, use cost optimization
|
|||
|
|
if cost_based_result and product_characteristics.get('unit_cost', 0) > 5:
|
|||
|
|
selected = cost_based_result
|
|||
|
|
logger.info("Selected cost-based safety stock (high-value product)")
|
|||
|
|
|
|||
|
|
# Otherwise use statistical approach
|
|||
|
|
else:
|
|||
|
|
selected = statistical_result
|
|||
|
|
logger.info("Selected statistical safety stock")
|
|||
|
|
|
|||
|
|
# Add shelf life constraint
|
|||
|
|
shelf_life = product_characteristics.get('shelf_life_days')
|
|||
|
|
if shelf_life:
|
|||
|
|
max_safe_stock = product_characteristics.get('avg_daily_demand', 0) * (shelf_life * 0.5)
|
|||
|
|
if selected['safety_stock'] > max_safe_stock:
|
|||
|
|
logger.warning(
|
|||
|
|
"Safety stock exceeds shelf life constraint",
|
|||
|
|
calculated=selected['safety_stock'],
|
|||
|
|
max_allowed=max_safe_stock
|
|||
|
|
)
|
|||
|
|
selected['safety_stock'] = round(max_safe_stock, 2)
|
|||
|
|
selected['constrained_by'] = 'shelf_life'
|
|||
|
|
|
|||
|
|
return selected
|
|||
|
|
|
|||
|
|
def _calculate_hardcoded_safety_stock(
|
|||
|
|
self,
|
|||
|
|
demand_stats: Dict[str, float],
|
|||
|
|
service_level: float = 0.95
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Calculate safety stock using current hardcoded 95% service level.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
demand_stats: Demand statistics
|
|||
|
|
service_level: Hardcoded service level (default 0.95)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Safety stock result with hardcoded approach
|
|||
|
|
"""
|
|||
|
|
z_score = stats.norm.ppf(service_level)
|
|||
|
|
mean_demand = demand_stats['mean_demand']
|
|||
|
|
std_demand = demand_stats['std_demand']
|
|||
|
|
mean_lead_time = demand_stats['mean_lead_time']
|
|||
|
|
|
|||
|
|
safety_stock = z_score * std_demand * np.sqrt(mean_lead_time)
|
|||
|
|
safety_stock = max(0, safety_stock)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'method': 'hardcoded_95_service_level',
|
|||
|
|
'safety_stock': round(safety_stock, 2),
|
|||
|
|
'service_level': service_level,
|
|||
|
|
'rationale': 'Current hardcoded 95% service level for all products'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _compare_with_hardcoded(
|
|||
|
|
self,
|
|||
|
|
optimal_result: Dict[str, Any],
|
|||
|
|
hardcoded_result: Dict[str, Any],
|
|||
|
|
cost_parameters: Optional[Dict[str, float]]
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Compare optimal safety stock with hardcoded approach.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
optimal_result: Optimal safety stock result
|
|||
|
|
hardcoded_result: Hardcoded approach result
|
|||
|
|
cost_parameters: Optional cost parameters for savings calculation
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Comparison metrics
|
|||
|
|
"""
|
|||
|
|
optimal_stock = optimal_result['safety_stock']
|
|||
|
|
hardcoded_stock = hardcoded_result['safety_stock']
|
|||
|
|
|
|||
|
|
stock_difference = optimal_stock - hardcoded_stock
|
|||
|
|
stock_difference_pct = (stock_difference / hardcoded_stock * 100) if hardcoded_stock > 0 else 0
|
|||
|
|
|
|||
|
|
comparison = {
|
|||
|
|
'stock_difference': round(stock_difference, 2),
|
|||
|
|
'stock_difference_pct': round(stock_difference_pct, 2),
|
|||
|
|
'optimal_service_level': optimal_result['service_level'],
|
|||
|
|
'hardcoded_service_level': hardcoded_result['service_level'],
|
|||
|
|
'service_level_difference': round(
|
|||
|
|
(optimal_result['service_level'] - hardcoded_result['service_level']) * 100, 2
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Calculate cost savings if cost data available
|
|||
|
|
if cost_parameters:
|
|||
|
|
holding_cost = cost_parameters.get('holding_cost_per_unit_per_day', 0.01)
|
|||
|
|
annual_holding_savings = stock_difference * holding_cost * 365
|
|||
|
|
|
|||
|
|
comparison['annual_holding_cost_savings'] = round(annual_holding_savings, 2)
|
|||
|
|
if hardcoded_stock > 0:
|
|||
|
|
comparison['cost_savings_pct'] = round(
|
|||
|
|
(annual_holding_savings / (hardcoded_stock * holding_cost * 365)) * 100, 2
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return comparison
|
|||
|
|
|
|||
|
|
def _generate_safety_stock_insights(
|
|||
|
|
self,
|
|||
|
|
tenant_id: str,
|
|||
|
|
inventory_product_id: str,
|
|||
|
|
optimal_result: Dict[str, Any],
|
|||
|
|
hardcoded_result: Dict[str, Any],
|
|||
|
|
comparison: Dict[str, Any],
|
|||
|
|
demand_stats: Dict[str, float],
|
|||
|
|
product_characteristics: Dict[str, Any]
|
|||
|
|
) -> List[Dict[str, Any]]:
|
|||
|
|
"""
|
|||
|
|
Generate actionable insights from safety stock optimization.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
tenant_id: Tenant ID
|
|||
|
|
inventory_product_id: Product ID
|
|||
|
|
optimal_result: Optimal safety stock result
|
|||
|
|
hardcoded_result: Hardcoded result
|
|||
|
|
comparison: Comparison metrics
|
|||
|
|
demand_stats: Demand statistics
|
|||
|
|
product_characteristics: Product characteristics
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
List of insights
|
|||
|
|
"""
|
|||
|
|
insights = []
|
|||
|
|
|
|||
|
|
stock_diff_pct = comparison['stock_difference_pct']
|
|||
|
|
|
|||
|
|
# Insight 1: Over-stocking reduction opportunity
|
|||
|
|
if stock_diff_pct < -10: # Optimal is >10% lower
|
|||
|
|
cost_savings = comparison.get('annual_holding_cost_savings', 0)
|
|||
|
|
|
|||
|
|
insights.append({
|
|||
|
|
'type': 'optimization',
|
|||
|
|
'priority': 'high' if abs(stock_diff_pct) > 25 else 'medium',
|
|||
|
|
'category': 'inventory',
|
|||
|
|
'title': f'Reduce Safety Stock by {abs(stock_diff_pct):.0f}%',
|
|||
|
|
'description': f'Product {inventory_product_id} is over-stocked. Optimal safety stock is {optimal_result["safety_stock"]:.1f} units vs current {hardcoded_result["safety_stock"]:.1f}. Reducing to optimal level saves €{abs(cost_savings):.2f}/year in holding costs while maintaining {optimal_result["service_level"]*100:.1f}% service level.',
|
|||
|
|
'impact_type': 'cost_savings',
|
|||
|
|
'impact_value': abs(cost_savings),
|
|||
|
|
'impact_unit': 'euros_per_year',
|
|||
|
|
'confidence': 85,
|
|||
|
|
'metrics_json': {
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'current_safety_stock': round(hardcoded_result['safety_stock'], 2),
|
|||
|
|
'optimal_safety_stock': round(optimal_result['safety_stock'], 2),
|
|||
|
|
'reduction_pct': round(abs(stock_diff_pct), 2),
|
|||
|
|
'annual_savings': round(abs(cost_savings), 2),
|
|||
|
|
'optimal_service_level': round(optimal_result['service_level'] * 100, 2)
|
|||
|
|
},
|
|||
|
|
'actionable': True,
|
|||
|
|
'recommendation_actions': [
|
|||
|
|
{
|
|||
|
|
'label': 'Update Safety Stock',
|
|||
|
|
'action': 'update_safety_stock',
|
|||
|
|
'params': {
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'new_safety_stock': round(optimal_result['safety_stock'], 2)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
'source_service': 'inventory',
|
|||
|
|
'source_model': 'safety_stock_optimizer'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Insight 2: Under-stocking risk
|
|||
|
|
elif stock_diff_pct > 10: # Optimal is >10% higher
|
|||
|
|
insights.append({
|
|||
|
|
'type': 'alert',
|
|||
|
|
'priority': 'high' if stock_diff_pct > 25 else 'medium',
|
|||
|
|
'category': 'inventory',
|
|||
|
|
'title': f'Increase Safety Stock by {stock_diff_pct:.0f}%',
|
|||
|
|
'description': f'Product {inventory_product_id} safety stock is too low. Current {hardcoded_result["safety_stock"]:.1f} units provides only {hardcoded_result["service_level"]*100:.0f}% service level. Increase to {optimal_result["safety_stock"]:.1f} for optimal {optimal_result["service_level"]*100:.1f}% service level.',
|
|||
|
|
'impact_type': 'stockout_risk_reduction',
|
|||
|
|
'impact_value': stock_diff_pct,
|
|||
|
|
'impact_unit': 'percentage',
|
|||
|
|
'confidence': 85,
|
|||
|
|
'metrics_json': {
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'current_safety_stock': round(hardcoded_result['safety_stock'], 2),
|
|||
|
|
'optimal_safety_stock': round(optimal_result['safety_stock'], 2),
|
|||
|
|
'increase_pct': round(stock_diff_pct, 2),
|
|||
|
|
'current_service_level': round(hardcoded_result['service_level'] * 100, 2),
|
|||
|
|
'optimal_service_level': round(optimal_result['service_level'] * 100, 2),
|
|||
|
|
'historical_stockout_rate': round(demand_stats['stockout_rate'] * 100, 2)
|
|||
|
|
},
|
|||
|
|
'actionable': True,
|
|||
|
|
'recommendation_actions': [
|
|||
|
|
{
|
|||
|
|
'label': 'Update Safety Stock',
|
|||
|
|
'action': 'update_safety_stock',
|
|||
|
|
'params': {
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'new_safety_stock': round(optimal_result['safety_stock'], 2)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
'source_service': 'inventory',
|
|||
|
|
'source_model': 'safety_stock_optimizer'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Insight 3: High demand variability
|
|||
|
|
if demand_stats['cv_demand'] > 0.5: # Coefficient of variation > 0.5
|
|||
|
|
insights.append({
|
|||
|
|
'type': 'insight',
|
|||
|
|
'priority': 'medium',
|
|||
|
|
'category': 'inventory',
|
|||
|
|
'title': f'High Demand Variability Detected',
|
|||
|
|
'description': f'Product {inventory_product_id} has high demand variability (CV={demand_stats["cv_demand"]:.2f}). This increases safety stock requirements. Consider demand smoothing strategies or more frequent orders.',
|
|||
|
|
'impact_type': 'operational_insight',
|
|||
|
|
'impact_value': demand_stats['cv_demand'],
|
|||
|
|
'impact_unit': 'coefficient_of_variation',
|
|||
|
|
'confidence': 90,
|
|||
|
|
'metrics_json': {
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'cv_demand': round(demand_stats['cv_demand'], 2),
|
|||
|
|
'mean_demand': round(demand_stats['mean_demand'], 2),
|
|||
|
|
'std_demand': round(demand_stats['std_demand'], 2)
|
|||
|
|
},
|
|||
|
|
'actionable': True,
|
|||
|
|
'recommendation_actions': [
|
|||
|
|
{
|
|||
|
|
'label': 'Review Demand Patterns',
|
|||
|
|
'action': 'analyze_demand_patterns',
|
|||
|
|
'params': {'inventory_product_id': inventory_product_id}
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
'source_service': 'inventory',
|
|||
|
|
'source_model': 'safety_stock_optimizer'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Insight 4: Frequent stockouts
|
|||
|
|
if demand_stats['stockout_rate'] > 0.1: # More than 10% stockout rate
|
|||
|
|
insights.append({
|
|||
|
|
'type': 'alert',
|
|||
|
|
'priority': 'critical' if demand_stats['stockout_rate'] > 0.2 else 'high',
|
|||
|
|
'category': 'inventory',
|
|||
|
|
'title': f'Frequent Stockouts: {demand_stats["stockout_rate"]*100:.1f}%',
|
|||
|
|
'description': f'Product {inventory_product_id} experiences frequent stockouts ({demand_stats["stockout_rate"]*100:.1f}% of days). Optimal safety stock of {optimal_result["safety_stock"]:.1f} units should reduce this significantly.',
|
|||
|
|
'impact_type': 'stockout_frequency',
|
|||
|
|
'impact_value': demand_stats['stockout_rate'] * 100,
|
|||
|
|
'impact_unit': 'percentage',
|
|||
|
|
'confidence': 95,
|
|||
|
|
'metrics_json': {
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'stockout_rate': round(demand_stats['stockout_rate'] * 100, 2),
|
|||
|
|
'stockout_frequency': demand_stats['stockout_frequency'],
|
|||
|
|
'optimal_safety_stock': round(optimal_result['safety_stock'], 2)
|
|||
|
|
},
|
|||
|
|
'actionable': True,
|
|||
|
|
'recommendation_actions': [
|
|||
|
|
{
|
|||
|
|
'label': 'URGENT: Update Safety Stock',
|
|||
|
|
'action': 'update_safety_stock',
|
|||
|
|
'params': {
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'new_safety_stock': round(optimal_result['safety_stock'], 2)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'label': 'Review Supplier Reliability',
|
|||
|
|
'action': 'review_supplier',
|
|||
|
|
'params': {'inventory_product_id': inventory_product_id}
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
'source_service': 'inventory',
|
|||
|
|
'source_model': 'safety_stock_optimizer'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return insights
|
|||
|
|
|
|||
|
|
def _insufficient_data_response(
|
|||
|
|
self,
|
|||
|
|
tenant_id: str,
|
|||
|
|
inventory_product_id: str,
|
|||
|
|
product_characteristics: Dict[str, Any]
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""Return response when insufficient data available."""
|
|||
|
|
# Use simple heuristic based on criticality
|
|||
|
|
criticality = product_characteristics.get('criticality', 'medium').lower()
|
|||
|
|
avg_daily_demand = product_characteristics.get('avg_daily_demand', 10)
|
|||
|
|
|
|||
|
|
# Simple rule: 7 days of demand for high, 5 for medium, 3 for low
|
|||
|
|
safety_stock_days = {'high': 7, 'medium': 5, 'low': 3}.get(criticality, 5)
|
|||
|
|
fallback_safety_stock = avg_daily_demand * safety_stock_days
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'tenant_id': tenant_id,
|
|||
|
|
'inventory_product_id': inventory_product_id,
|
|||
|
|
'optimized_at': datetime.utcnow().isoformat(),
|
|||
|
|
'history_days': 0,
|
|||
|
|
'demand_stats': {},
|
|||
|
|
'optimal_result': {
|
|||
|
|
'method': 'fallback_heuristic',
|
|||
|
|
'safety_stock': round(fallback_safety_stock, 2),
|
|||
|
|
'service_level': 0.95,
|
|||
|
|
'rationale': f'Insufficient data. Using {safety_stock_days} days of demand for {criticality} criticality.'
|
|||
|
|
},
|
|||
|
|
'hardcoded_result': None,
|
|||
|
|
'comparison': {},
|
|||
|
|
'insights': []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def get_optimal_safety_stock(self, inventory_product_id: str) -> Optional[float]:
|
|||
|
|
"""Get cached optimal safety stock for a product."""
|
|||
|
|
return self.optimal_stocks.get(inventory_product_id)
|
|||
|
|
|
|||
|
|
def get_learned_service_level(self, inventory_product_id: str) -> Optional[float]:
|
|||
|
|
"""Get learned optimal service level for a product."""
|
|||
|
|
return self.learned_service_levels.get(inventory_product_id)
|