Improve AI logic
This commit is contained in:
350
services/inventory/app/ml/safety_stock_insights_orchestrator.py
Normal file
350
services/inventory/app/ml/safety_stock_insights_orchestrator.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Safety Stock Insights Orchestrator
|
||||
Coordinates safety stock optimization and insight posting
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Any, Optional
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add shared clients to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||
from shared.clients.ai_insights_client import AIInsightsClient
|
||||
|
||||
from app.ml.safety_stock_optimizer import SafetyStockOptimizer
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SafetyStockInsightsOrchestrator:
|
||||
"""
|
||||
Orchestrates safety stock optimization and insight generation workflow.
|
||||
|
||||
Workflow:
|
||||
1. Optimize safety stock from demand history and cost parameters
|
||||
2. Generate insights comparing optimal vs hardcoded approach
|
||||
3. Post insights to AI Insights Service
|
||||
4. Provide optimized safety stock levels for inventory management
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ai_insights_base_url: str = "http://ai-insights-service:8000"
|
||||
):
|
||||
self.optimizer = SafetyStockOptimizer()
|
||||
self.ai_insights_client = AIInsightsClient(ai_insights_base_url)
|
||||
|
||||
async def optimize_and_post_insights(
|
||||
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]:
|
||||
"""
|
||||
Complete workflow: Optimize safety stock and post insights.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
inventory_product_id: Product identifier
|
||||
demand_history: Historical demand data
|
||||
product_characteristics: Product properties
|
||||
cost_parameters: Optional cost parameters
|
||||
supplier_reliability: Optional supplier on-time rate
|
||||
min_history_days: Minimum days of history required
|
||||
|
||||
Returns:
|
||||
Workflow results with optimization and posted insights
|
||||
"""
|
||||
logger.info(
|
||||
"Starting safety stock optimization workflow",
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
history_days=len(demand_history)
|
||||
)
|
||||
|
||||
# Step 1: Optimize safety stock
|
||||
optimization_results = await self.optimizer.optimize_safety_stock(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
demand_history=demand_history,
|
||||
product_characteristics=product_characteristics,
|
||||
cost_parameters=cost_parameters,
|
||||
supplier_reliability=supplier_reliability,
|
||||
min_history_days=min_history_days
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Safety stock optimization complete",
|
||||
inventory_product_id=inventory_product_id,
|
||||
optimal_stock=optimization_results.get('optimal_result', {}).get('safety_stock'),
|
||||
insights_generated=len(optimization_results.get('insights', []))
|
||||
)
|
||||
|
||||
# Step 2: Enrich insights with tenant_id and product context
|
||||
enriched_insights = self._enrich_insights(
|
||||
optimization_results.get('insights', []),
|
||||
tenant_id,
|
||||
inventory_product_id
|
||||
)
|
||||
|
||||
# Step 3: Post insights to AI Insights Service
|
||||
if enriched_insights:
|
||||
post_results = await self.ai_insights_client.create_insights_bulk(
|
||||
tenant_id=UUID(tenant_id),
|
||||
insights=enriched_insights
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Safety stock insights posted to AI Insights Service",
|
||||
inventory_product_id=inventory_product_id,
|
||||
total=post_results['total'],
|
||||
successful=post_results['successful'],
|
||||
failed=post_results['failed']
|
||||
)
|
||||
else:
|
||||
post_results = {'total': 0, 'successful': 0, 'failed': 0}
|
||||
logger.info("No insights to post for product", inventory_product_id=inventory_product_id)
|
||||
|
||||
# Step 4: Return comprehensive results
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'optimized_at': optimization_results['optimized_at'],
|
||||
'history_days': optimization_results['history_days'],
|
||||
'optimal_safety_stock': optimization_results.get('optimal_result', {}).get('safety_stock'),
|
||||
'optimal_service_level': optimization_results.get('optimal_result', {}).get('service_level'),
|
||||
'cost_savings': optimization_results.get('comparison', {}).get('annual_holding_cost_savings'),
|
||||
'insights_generated': len(enriched_insights),
|
||||
'insights_posted': post_results['successful'],
|
||||
'insights_failed': post_results['failed'],
|
||||
'created_insights': post_results.get('created_insights', [])
|
||||
}
|
||||
|
||||
def _enrich_insights(
|
||||
self,
|
||||
insights: List[Dict[str, Any]],
|
||||
tenant_id: str,
|
||||
inventory_product_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Enrich insights with required fields for AI Insights Service.
|
||||
|
||||
Args:
|
||||
insights: Raw insights from optimizer
|
||||
tenant_id: Tenant identifier
|
||||
inventory_product_id: Product identifier
|
||||
|
||||
Returns:
|
||||
Enriched insights ready for posting
|
||||
"""
|
||||
enriched = []
|
||||
|
||||
for insight in insights:
|
||||
# Add required tenant_id
|
||||
enriched_insight = insight.copy()
|
||||
enriched_insight['tenant_id'] = tenant_id
|
||||
|
||||
# Add product context to metrics
|
||||
if 'metrics_json' not in enriched_insight:
|
||||
enriched_insight['metrics_json'] = {}
|
||||
|
||||
enriched_insight['metrics_json']['inventory_product_id'] = inventory_product_id
|
||||
|
||||
# Add source metadata
|
||||
enriched_insight['source_service'] = 'inventory'
|
||||
enriched_insight['source_model'] = 'safety_stock_optimizer'
|
||||
enriched_insight['detected_at'] = datetime.utcnow().isoformat()
|
||||
|
||||
enriched.append(enriched_insight)
|
||||
|
||||
return enriched
|
||||
|
||||
async def optimize_all_products(
|
||||
self,
|
||||
tenant_id: str,
|
||||
products_data: Dict[str, Dict[str, Any]],
|
||||
min_history_days: int = 90
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Optimize safety stock for all products for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
products_data: Dict of {inventory_product_id: {
|
||||
'demand_history': DataFrame,
|
||||
'product_characteristics': dict,
|
||||
'cost_parameters': dict (optional),
|
||||
'supplier_reliability': float (optional)
|
||||
}}
|
||||
min_history_days: Minimum days of history required
|
||||
|
||||
Returns:
|
||||
Comprehensive optimization results
|
||||
"""
|
||||
logger.info(
|
||||
"Optimizing safety stock for all products",
|
||||
tenant_id=tenant_id,
|
||||
products=len(products_data)
|
||||
)
|
||||
|
||||
all_results = []
|
||||
total_insights_posted = 0
|
||||
total_cost_savings = 0.0
|
||||
|
||||
# Optimize each product
|
||||
for inventory_product_id, product_data in products_data.items():
|
||||
try:
|
||||
results = await self.optimize_and_post_insights(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
demand_history=product_data['demand_history'],
|
||||
product_characteristics=product_data['product_characteristics'],
|
||||
cost_parameters=product_data.get('cost_parameters'),
|
||||
supplier_reliability=product_data.get('supplier_reliability'),
|
||||
min_history_days=min_history_days
|
||||
)
|
||||
|
||||
all_results.append(results)
|
||||
total_insights_posted += results['insights_posted']
|
||||
|
||||
if results.get('cost_savings'):
|
||||
total_cost_savings += results['cost_savings']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error optimizing product",
|
||||
inventory_product_id=inventory_product_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Generate summary insight
|
||||
if total_cost_savings > 0:
|
||||
summary_insight = self._generate_portfolio_summary_insight(
|
||||
tenant_id, all_results, total_cost_savings
|
||||
)
|
||||
|
||||
if summary_insight:
|
||||
enriched_summary = self._enrich_insights(
|
||||
[summary_insight], tenant_id, 'all_products'
|
||||
)
|
||||
|
||||
post_results = await self.ai_insights_client.create_insights_bulk(
|
||||
tenant_id=UUID(tenant_id),
|
||||
insights=enriched_summary
|
||||
)
|
||||
|
||||
total_insights_posted += post_results['successful']
|
||||
|
||||
logger.info(
|
||||
"All products optimization complete",
|
||||
tenant_id=tenant_id,
|
||||
products_optimized=len(all_results),
|
||||
total_insights_posted=total_insights_posted,
|
||||
total_annual_savings=total_cost_savings
|
||||
)
|
||||
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'optimized_at': datetime.utcnow().isoformat(),
|
||||
'products_optimized': len(all_results),
|
||||
'product_results': all_results,
|
||||
'total_insights_posted': total_insights_posted,
|
||||
'total_annual_cost_savings': round(total_cost_savings, 2)
|
||||
}
|
||||
|
||||
def _generate_portfolio_summary_insight(
|
||||
self,
|
||||
tenant_id: str,
|
||||
all_results: List[Dict[str, Any]],
|
||||
total_cost_savings: float
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Generate portfolio-level summary insight.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
all_results: All product optimization results
|
||||
total_cost_savings: Total annual cost savings
|
||||
|
||||
Returns:
|
||||
Summary insight or None
|
||||
"""
|
||||
if total_cost_savings < 100: # Only if meaningful savings
|
||||
return None
|
||||
|
||||
products_optimized = len(all_results)
|
||||
products_with_savings = len([r for r in all_results if r.get('cost_savings', 0) > 0])
|
||||
|
||||
return {
|
||||
'type': 'optimization',
|
||||
'priority': 'high' if total_cost_savings > 1000 else 'medium',
|
||||
'category': 'inventory',
|
||||
'title': f'Portfolio Safety Stock Optimization: €{total_cost_savings:.0f}/year Savings',
|
||||
'description': f'Optimized safety stock across {products_optimized} products. {products_with_savings} products have over-stocked inventory. Implementing optimal levels saves €{total_cost_savings:.2f} annually in holding costs while maintaining or improving service levels.',
|
||||
'impact_type': 'cost_savings',
|
||||
'impact_value': total_cost_savings,
|
||||
'impact_unit': 'euros_per_year',
|
||||
'confidence': 85,
|
||||
'metrics_json': {
|
||||
'products_optimized': products_optimized,
|
||||
'products_with_savings': products_with_savings,
|
||||
'total_annual_savings': round(total_cost_savings, 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Apply All Optimizations',
|
||||
'action': 'apply_all_safety_stock_optimizations',
|
||||
'params': {'tenant_id': tenant_id}
|
||||
},
|
||||
{
|
||||
'label': 'Review Individual Products',
|
||||
'action': 'review_safety_stock_insights',
|
||||
'params': {'tenant_id': tenant_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'inventory',
|
||||
'source_model': 'safety_stock_optimizer'
|
||||
}
|
||||
|
||||
async def get_optimal_safety_stock(
|
||||
self,
|
||||
inventory_product_id: str
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Get cached optimal safety stock for a product.
|
||||
|
||||
Args:
|
||||
inventory_product_id: Product identifier
|
||||
|
||||
Returns:
|
||||
Optimal safety stock or None if not optimized
|
||||
"""
|
||||
return self.optimizer.get_optimal_safety_stock(inventory_product_id)
|
||||
|
||||
async def get_learned_service_level(
|
||||
self,
|
||||
inventory_product_id: str
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Get learned optimal service level for a product.
|
||||
|
||||
Args:
|
||||
inventory_product_id: Product identifier
|
||||
|
||||
Returns:
|
||||
Optimal service level (0-1) or None if not learned
|
||||
"""
|
||||
return self.optimizer.get_learned_service_level(inventory_product_id)
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client connections."""
|
||||
await self.ai_insights_client.close()
|
||||
755
services/inventory/app/ml/safety_stock_optimizer.py
Normal file
755
services/inventory/app/ml/safety_stock_optimizer.py
Normal file
@@ -0,0 +1,755 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user