Files
bakery-ia/services/procurement/app/services/safety_stock_calculator.py
Claude 9d284cae46 refactor: Convert internal services to structured JSON reasoning
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.
2025-11-07 19:22:02 +00:00

475 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
}