Convert pipe-separated reasoning codes to structured JSON format for: - Safety stock calculator (statistical calculations, errors) - Price forecaster (procurement recommendations, volatility) - Order optimization (EOQ, tier pricing) This enables i18n translation of internal calculation reasoning and provides structured data for frontend AI insights display. Benefits: - Consistent with PO/Batch reasoning_data format - Frontend can translate using same i18n infrastructure - Structured parameters enable rich UI visualization - No legacy string parsing needed Changes: - safety_stock_calculator.py: Replace reasoning str with reasoning_data dict - price_forecaster.py: Convert recommendation reasoning to structured format - optimization.py: Update EOQ and tier pricing to use reasoning_data Part of complete i18n implementation for AI insights.
475 lines
15 KiB
Python
475 lines
15 KiB
Python
"""
|
||
Safety Stock Calculator
|
||
|
||
Calculates dynamic safety stock based on demand variability,
|
||
lead time, and service level targets.
|
||
"""
|
||
|
||
import math
|
||
import statistics
|
||
from decimal import Decimal
|
||
from typing import List, Dict, Optional, Tuple
|
||
from dataclasses import dataclass
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class SafetyStockResult:
|
||
"""Result of safety stock calculation"""
|
||
safety_stock_quantity: Decimal
|
||
service_level: float
|
||
z_score: float
|
||
demand_std_dev: float
|
||
lead_time_days: int
|
||
calculation_method: str
|
||
confidence: str # 'high', 'medium', 'low'
|
||
reasoning_data: dict
|
||
|
||
|
||
@dataclass
|
||
class DemandHistory:
|
||
"""Historical demand data for an ingredient"""
|
||
ingredient_id: str
|
||
daily_demands: List[float] # Historical daily demands
|
||
mean_demand: float
|
||
std_dev: float
|
||
coefficient_of_variation: float
|
||
|
||
|
||
class SafetyStockCalculator:
|
||
"""
|
||
Calculates safety stock using statistical methods.
|
||
|
||
Formula: Safety Stock = Z × σ × √L
|
||
where:
|
||
- Z = service level z-score (e.g., 1.96 for 97.5%)
|
||
- σ = demand standard deviation
|
||
- L = lead time in days
|
||
|
||
This accounts for demand variability during lead time.
|
||
"""
|
||
|
||
# Z-scores for common service levels
|
||
SERVICE_LEVEL_Z_SCORES = {
|
||
0.50: 0.00, # 50% - no buffer (not recommended)
|
||
0.80: 0.84, # 80% service level
|
||
0.85: 1.04, # 85% service level
|
||
0.90: 1.28, # 90% service level
|
||
0.95: 1.65, # 95% service level
|
||
0.975: 1.96, # 97.5% service level
|
||
0.99: 2.33, # 99% service level
|
||
0.995: 2.58, # 99.5% service level
|
||
0.999: 3.09 # 99.9% service level
|
||
}
|
||
|
||
def __init__(self, default_service_level: float = 0.95):
|
||
"""
|
||
Initialize safety stock calculator.
|
||
|
||
Args:
|
||
default_service_level: Default target service level (0-1)
|
||
"""
|
||
self.default_service_level = default_service_level
|
||
|
||
def calculate_safety_stock(
|
||
self,
|
||
demand_std_dev: float,
|
||
lead_time_days: int,
|
||
service_level: Optional[float] = None
|
||
) -> SafetyStockResult:
|
||
"""
|
||
Calculate safety stock using standard formula.
|
||
|
||
Safety Stock = Z × σ × √L
|
||
|
||
Args:
|
||
demand_std_dev: Standard deviation of daily demand
|
||
lead_time_days: Supplier lead time in days
|
||
service_level: Target service level (uses default if None)
|
||
|
||
Returns:
|
||
SafetyStockResult with calculation details
|
||
"""
|
||
if service_level is None:
|
||
service_level = self.default_service_level
|
||
|
||
# Get z-score for service level
|
||
z_score = self._get_z_score(service_level)
|
||
|
||
# Calculate safety stock
|
||
if lead_time_days <= 0 or demand_std_dev <= 0:
|
||
return SafetyStockResult(
|
||
safety_stock_quantity=Decimal('0'),
|
||
service_level=service_level,
|
||
z_score=z_score,
|
||
demand_std_dev=demand_std_dev,
|
||
lead_time_days=lead_time_days,
|
||
calculation_method='zero_due_to_invalid_inputs',
|
||
confidence='low',
|
||
reasoning_data={
|
||
'type': 'error_lead_time_invalid',
|
||
'parameters': {
|
||
'lead_time_days': lead_time_days,
|
||
'demand_std_dev': demand_std_dev
|
||
}
|
||
}
|
||
)
|
||
|
||
# Safety Stock = Z × σ × √L
|
||
safety_stock = z_score * demand_std_dev * math.sqrt(lead_time_days)
|
||
|
||
# Determine confidence
|
||
confidence = self._determine_confidence(demand_std_dev, lead_time_days)
|
||
|
||
return SafetyStockResult(
|
||
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
|
||
service_level=service_level,
|
||
z_score=z_score,
|
||
demand_std_dev=demand_std_dev,
|
||
lead_time_days=lead_time_days,
|
||
calculation_method='statistical_z_score',
|
||
confidence=confidence,
|
||
reasoning_data={
|
||
'type': 'statistical_z_score',
|
||
'calculation_method': 'statistical_z_score',
|
||
'parameters': {
|
||
'service_level': round(service_level * 100, 1),
|
||
'z_score': round(z_score, 2),
|
||
'demand_std_dev': round(demand_std_dev, 2),
|
||
'lead_time_days': lead_time_days,
|
||
'safety_stock': round(safety_stock, 2)
|
||
},
|
||
'confidence': confidence
|
||
}
|
||
)
|
||
|
||
def calculate_from_demand_history(
|
||
self,
|
||
daily_demands: List[float],
|
||
lead_time_days: int,
|
||
service_level: Optional[float] = None
|
||
) -> SafetyStockResult:
|
||
"""
|
||
Calculate safety stock from historical demand data.
|
||
|
||
Args:
|
||
daily_demands: List of historical daily demands
|
||
lead_time_days: Supplier lead time in days
|
||
service_level: Target service level
|
||
|
||
Returns:
|
||
SafetyStockResult with calculation details
|
||
"""
|
||
if not daily_demands or len(daily_demands) < 2:
|
||
logger.warning("Insufficient demand history for safety stock calculation")
|
||
return SafetyStockResult(
|
||
safety_stock_quantity=Decimal('0'),
|
||
service_level=service_level or self.default_service_level,
|
||
z_score=0.0,
|
||
demand_std_dev=0.0,
|
||
lead_time_days=lead_time_days,
|
||
calculation_method='insufficient_data',
|
||
confidence='low',
|
||
reasoning_data={
|
||
'type': 'error_insufficient_data',
|
||
'parameters': {
|
||
'data_points': len(daily_demands) if daily_demands else 0,
|
||
'min_required': 2
|
||
}
|
||
}
|
||
)
|
||
|
||
# Calculate standard deviation
|
||
demand_std_dev = statistics.stdev(daily_demands)
|
||
|
||
return self.calculate_safety_stock(
|
||
demand_std_dev=demand_std_dev,
|
||
lead_time_days=lead_time_days,
|
||
service_level=service_level
|
||
)
|
||
|
||
def calculate_with_lead_time_variability(
|
||
self,
|
||
demand_mean: float,
|
||
demand_std_dev: float,
|
||
lead_time_mean: int,
|
||
lead_time_std_dev: int,
|
||
service_level: Optional[float] = None
|
||
) -> SafetyStockResult:
|
||
"""
|
||
Calculate safety stock considering both demand AND lead time variability.
|
||
|
||
More accurate formula:
|
||
SS = Z × √(L_mean × σ_demand² + μ_demand² × σ_lead_time²)
|
||
|
||
Args:
|
||
demand_mean: Mean daily demand
|
||
demand_std_dev: Standard deviation of daily demand
|
||
lead_time_mean: Mean lead time in days
|
||
lead_time_std_dev: Standard deviation of lead time
|
||
service_level: Target service level
|
||
|
||
Returns:
|
||
SafetyStockResult with calculation details
|
||
"""
|
||
if service_level is None:
|
||
service_level = self.default_service_level
|
||
|
||
z_score = self._get_z_score(service_level)
|
||
|
||
# Calculate combined variance
|
||
variance = (
|
||
lead_time_mean * (demand_std_dev ** 2) +
|
||
(demand_mean ** 2) * (lead_time_std_dev ** 2)
|
||
)
|
||
|
||
safety_stock = z_score * math.sqrt(variance)
|
||
|
||
confidence = 'high' if lead_time_std_dev > 0 else 'medium'
|
||
|
||
return SafetyStockResult(
|
||
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
|
||
service_level=service_level,
|
||
z_score=z_score,
|
||
demand_std_dev=demand_std_dev,
|
||
lead_time_days=lead_time_mean,
|
||
calculation_method='statistical_with_lead_time_variability',
|
||
confidence=confidence,
|
||
reasoning_data={
|
||
'type': 'advanced_variability',
|
||
'calculation_method': 'statistical_with_lead_time_variability',
|
||
'parameters': {
|
||
'service_level': round(service_level * 100, 1),
|
||
'z_score': round(z_score, 2),
|
||
'demand_mean': round(demand_mean, 2),
|
||
'demand_std_dev': round(demand_std_dev, 2),
|
||
'lead_time_mean': lead_time_mean,
|
||
'lead_time_std_dev': round(lead_time_std_dev, 1),
|
||
'safety_stock': round(safety_stock, 2)
|
||
},
|
||
'confidence': confidence
|
||
}
|
||
)
|
||
|
||
def calculate_using_fixed_percentage(
|
||
self,
|
||
average_demand: float,
|
||
lead_time_days: int,
|
||
percentage: float = 0.20
|
||
) -> SafetyStockResult:
|
||
"""
|
||
Calculate safety stock as percentage of lead time demand.
|
||
|
||
Simple method: Safety Stock = % × (Average Daily Demand × Lead Time)
|
||
|
||
Args:
|
||
average_demand: Average daily demand
|
||
lead_time_days: Supplier lead time in days
|
||
percentage: Safety stock percentage (default 20%)
|
||
|
||
Returns:
|
||
SafetyStockResult with calculation details
|
||
"""
|
||
lead_time_demand = average_demand * lead_time_days
|
||
safety_stock = lead_time_demand * percentage
|
||
|
||
return SafetyStockResult(
|
||
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
|
||
service_level=0.0, # Not based on service level
|
||
z_score=0.0,
|
||
demand_std_dev=0.0,
|
||
lead_time_days=lead_time_days,
|
||
calculation_method='fixed_percentage',
|
||
confidence='low',
|
||
reasoning_data={
|
||
'type': 'fixed_percentage',
|
||
'calculation_method': 'fixed_percentage',
|
||
'parameters': {
|
||
'percentage': round(percentage * 100, 0),
|
||
'average_demand': round(average_demand, 2),
|
||
'lead_time_days': lead_time_days,
|
||
'lead_time_demand': round(lead_time_demand, 2),
|
||
'safety_stock': round(safety_stock, 2)
|
||
},
|
||
'confidence': 'low'
|
||
}
|
||
)
|
||
|
||
def calculate_batch_safety_stock(
|
||
self,
|
||
ingredients_data: List[Dict]
|
||
) -> Dict[str, SafetyStockResult]:
|
||
"""
|
||
Calculate safety stock for multiple ingredients.
|
||
|
||
Args:
|
||
ingredients_data: List of dicts with ingredient data
|
||
|
||
Returns:
|
||
Dictionary mapping ingredient_id to SafetyStockResult
|
||
"""
|
||
results = {}
|
||
|
||
for data in ingredients_data:
|
||
ingredient_id = data['ingredient_id']
|
||
|
||
if 'daily_demands' in data:
|
||
# Use historical data
|
||
result = self.calculate_from_demand_history(
|
||
daily_demands=data['daily_demands'],
|
||
lead_time_days=data['lead_time_days'],
|
||
service_level=data.get('service_level')
|
||
)
|
||
elif 'demand_std_dev' in data:
|
||
# Use provided std dev
|
||
result = self.calculate_safety_stock(
|
||
demand_std_dev=data['demand_std_dev'],
|
||
lead_time_days=data['lead_time_days'],
|
||
service_level=data.get('service_level')
|
||
)
|
||
else:
|
||
# Fallback to percentage method
|
||
result = self.calculate_using_fixed_percentage(
|
||
average_demand=data.get('average_demand', 0),
|
||
lead_time_days=data['lead_time_days'],
|
||
percentage=data.get('safety_percentage', 0.20)
|
||
)
|
||
|
||
results[ingredient_id] = result
|
||
|
||
logger.info(f"Calculated safety stock for {len(results)} ingredients")
|
||
|
||
return results
|
||
|
||
def analyze_demand_history(
|
||
self,
|
||
daily_demands: List[float]
|
||
) -> DemandHistory:
|
||
"""
|
||
Analyze demand history to extract statistics.
|
||
|
||
Args:
|
||
daily_demands: List of historical daily demands
|
||
|
||
Returns:
|
||
DemandHistory with statistics
|
||
"""
|
||
if not daily_demands:
|
||
return DemandHistory(
|
||
ingredient_id="unknown",
|
||
daily_demands=[],
|
||
mean_demand=0.0,
|
||
std_dev=0.0,
|
||
coefficient_of_variation=0.0
|
||
)
|
||
|
||
mean_demand = statistics.mean(daily_demands)
|
||
std_dev = statistics.stdev(daily_demands) if len(daily_demands) >= 2 else 0.0
|
||
cv = (std_dev / mean_demand) if mean_demand > 0 else 0.0
|
||
|
||
return DemandHistory(
|
||
ingredient_id="unknown",
|
||
daily_demands=daily_demands,
|
||
mean_demand=mean_demand,
|
||
std_dev=std_dev,
|
||
coefficient_of_variation=cv
|
||
)
|
||
|
||
def _get_z_score(self, service_level: float) -> float:
|
||
"""
|
||
Get z-score for service level.
|
||
|
||
Args:
|
||
service_level: Target service level (0-1)
|
||
|
||
Returns:
|
||
Z-score
|
||
"""
|
||
# Find closest service level
|
||
if service_level in self.SERVICE_LEVEL_Z_SCORES:
|
||
return self.SERVICE_LEVEL_Z_SCORES[service_level]
|
||
|
||
# Interpolate or use closest
|
||
levels = sorted(self.SERVICE_LEVEL_Z_SCORES.keys())
|
||
|
||
for i, level in enumerate(levels):
|
||
if service_level <= level:
|
||
return self.SERVICE_LEVEL_Z_SCORES[level]
|
||
|
||
# Use highest if beyond range
|
||
return self.SERVICE_LEVEL_Z_SCORES[levels[-1]]
|
||
|
||
def _determine_confidence(
|
||
self,
|
||
demand_std_dev: float,
|
||
lead_time_days: int
|
||
) -> str:
|
||
"""
|
||
Determine confidence level of calculation.
|
||
|
||
Args:
|
||
demand_std_dev: Demand standard deviation
|
||
lead_time_days: Lead time in days
|
||
|
||
Returns:
|
||
Confidence level
|
||
"""
|
||
if demand_std_dev == 0:
|
||
return 'low' # No variability in data
|
||
|
||
if lead_time_days < 3:
|
||
return 'high' # Short lead time, easier to manage
|
||
elif lead_time_days < 7:
|
||
return 'medium'
|
||
else:
|
||
return 'medium' # Long lead time, more uncertainty
|
||
|
||
def recommend_service_level(
|
||
self,
|
||
ingredient_category: str,
|
||
is_critical: bool = False
|
||
) -> float:
|
||
"""
|
||
Recommend service level based on ingredient characteristics.
|
||
|
||
Args:
|
||
ingredient_category: Category of ingredient
|
||
is_critical: Whether ingredient is business-critical
|
||
|
||
Returns:
|
||
Recommended service level
|
||
"""
|
||
# Critical ingredients: very high service level
|
||
if is_critical:
|
||
return 0.99
|
||
|
||
# Perishables: moderate service level (to avoid waste)
|
||
if ingredient_category.lower() in ['dairy', 'meat', 'produce', 'fresh']:
|
||
return 0.90
|
||
|
||
# Standard ingredients: high service level
|
||
return 0.95
|
||
|
||
def export_to_dict(self, result: SafetyStockResult) -> Dict:
|
||
"""
|
||
Export result to dictionary for API response.
|
||
|
||
Args:
|
||
result: SafetyStockResult
|
||
|
||
Returns:
|
||
Dictionary representation
|
||
"""
|
||
return {
|
||
'safety_stock_quantity': float(result.safety_stock_quantity),
|
||
'service_level': result.service_level,
|
||
'z_score': result.z_score,
|
||
'demand_std_dev': result.demand_std_dev,
|
||
'lead_time_days': result.lead_time_days,
|
||
'calculation_method': result.calculation_method,
|
||
'confidence': result.confidence,
|
||
'reasoning_data': result.reasoning_data
|
||
}
|