Improve AI logic

This commit is contained in:
Urtzi Alfaro
2025-11-05 13:34:56 +01:00
parent 5c87fbcf48
commit 394ad3aea4
218 changed files with 30627 additions and 7658 deletions

View 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()

View 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)