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

@@ -388,7 +388,8 @@ async def resolve_or_create_products_batch(
request: BatchProductResolutionRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
classifier: ProductClassifierService = Depends(get_product_classifier)
):
"""Resolve or create multiple products in a single optimized operation for sales import"""
try:
@@ -415,11 +416,14 @@ async def resolve_or_create_products_batch(
resolved_count += 1
logger.debug("Resolved existing product", product=product_name, tenant_id=tenant_id)
else:
category = product_data.get('category', 'general')
# Use the product classifier to determine the appropriate type
suggestion = classifier.classify_product(product_name)
category = product_data.get('category', suggestion.category if hasattr(suggestion, 'category') else 'general')
ingredient_data = {
'name': product_name,
'type': 'finished_product',
'unit': 'unit',
'type': suggestion.product_type.value if hasattr(suggestion, 'product_type') else 'finished_product',
'unit': suggestion.unit_of_measure.value if hasattr(suggestion, 'unit_of_measure') else 'unit',
'current_stock': 0,
'reorder_point': 0,
'cost_per_unit': 0,
@@ -429,7 +433,8 @@ async def resolve_or_create_products_batch(
created = await service.create_ingredient_fast(ingredient_data, tenant_id, db)
product_mappings[product_name] = str(created.id)
created_count += 1
logger.debug("Created new product", product=product_name, tenant_id=tenant_id)
logger.debug("Created new product", product=product_name,
product_type=ingredient_data['type'], tenant_id=tenant_id)
except Exception as e:
logger.warning("Failed to resolve/create product",

View File

@@ -0,0 +1,297 @@
"""
ML Insights API Endpoints for Inventory Service
Provides endpoints to trigger ML insight generation for:
- Safety stock optimization
- Inventory level recommendations
- Demand pattern analysis
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional, List
from uuid import UUID
from datetime import datetime, timedelta
import structlog
import pandas as pd
from app.core.database import get_db
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
router = APIRouter(
prefix="/api/v1/tenants/{tenant_id}/inventory/ml/insights",
tags=["ML Insights"]
)
# ================================================================
# REQUEST/RESPONSE SCHEMAS
# ================================================================
class SafetyStockOptimizationRequest(BaseModel):
"""Request schema for safety stock optimization"""
product_ids: Optional[List[str]] = Field(
None,
description="Specific product IDs to optimize. If None, optimizes all products"
)
lookback_days: int = Field(
90,
description="Days of historical demand to analyze",
ge=30,
le=365
)
min_history_days: int = Field(
30,
description="Minimum days of history required",
ge=7,
le=180
)
class SafetyStockOptimizationResponse(BaseModel):
"""Response schema for safety stock optimization"""
success: bool
message: str
tenant_id: str
products_optimized: int
total_insights_generated: int
total_insights_posted: int
total_cost_savings: float
insights_by_product: dict
errors: List[str] = []
# ================================================================
# API ENDPOINTS
# ================================================================
@router.post("/optimize-safety-stock", response_model=SafetyStockOptimizationResponse)
async def trigger_safety_stock_optimization(
tenant_id: str,
request_data: SafetyStockOptimizationRequest,
db: AsyncSession = Depends(get_db)
):
"""
Trigger safety stock optimization for inventory products.
This endpoint:
1. Fetches historical demand data for specified products
2. Runs the SafetyStockInsightsOrchestrator to optimize levels
3. Generates insights about safety stock recommendations
4. Posts insights to AI Insights Service
Args:
tenant_id: Tenant UUID
request_data: Optimization parameters
db: Database session
Returns:
SafetyStockOptimizationResponse with optimization results
"""
logger.info(
"ML insights safety stock optimization requested",
tenant_id=tenant_id,
product_ids=request_data.product_ids,
lookback_days=request_data.lookback_days
)
try:
# Import ML orchestrator
from app.ml.safety_stock_insights_orchestrator import SafetyStockInsightsOrchestrator
from app.models.inventory import Ingredient
from sqlalchemy import select
# Initialize orchestrator
orchestrator = SafetyStockInsightsOrchestrator()
# Get products to optimize
if request_data.product_ids:
query = select(Ingredient).where(
Ingredient.tenant_id == UUID(tenant_id),
Ingredient.id.in_([UUID(pid) for pid in request_data.product_ids])
)
else:
query = select(Ingredient).where(
Ingredient.tenant_id == UUID(tenant_id)
).limit(10) # Limit to prevent timeout
result = await db.execute(query)
products = result.scalars().all()
if not products:
return SafetyStockOptimizationResponse(
success=False,
message="No products found for optimization",
tenant_id=tenant_id,
products_optimized=0,
total_insights_generated=0,
total_insights_posted=0,
total_cost_savings=0.0,
insights_by_product={},
errors=["No products found"]
)
# Calculate date range for demand history
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=request_data.lookback_days)
# Process each product
total_insights_generated = 0
total_insights_posted = 0
total_cost_savings = 0.0
insights_by_product = {}
errors = []
for product in products:
try:
product_id = str(product.id)
logger.info(f"Optimizing safety stock for {product.name} ({product_id})")
# Fetch real sales/demand history from sales service
from shared.clients.sales_client import SalesServiceClient
from app.core.config import settings
sales_client = SalesServiceClient(settings)
try:
# Fetch sales data for this product
sales_response = await sales_client.get_sales_by_product(
tenant_id=tenant_id,
product_id=product_id,
start_date=start_date.strftime('%Y-%m-%d'),
end_date=end_date.strftime('%Y-%m-%d')
)
if not sales_response or not sales_response.get('sales'):
logger.warning(
f"No sales history for product {product_id}, skipping"
)
continue
# Convert sales data to daily demand
sales_data = sales_response.get('sales', [])
demand_data = []
for sale in sales_data:
demand_data.append({
'date': pd.to_datetime(sale.get('date') or sale.get('sale_date')),
'quantity': float(sale.get('quantity', 0))
})
if not demand_data:
logger.warning(
f"No valid demand data for product {product_id}, skipping"
)
continue
demand_history = pd.DataFrame(demand_data)
# Aggregate by date if there are multiple sales per day
demand_history = demand_history.groupby('date').agg({
'quantity': 'sum'
}).reset_index()
if len(demand_history) < request_data.min_history_days:
logger.warning(
f"Insufficient demand history for product {product_id}: "
f"{len(demand_history)} days < {request_data.min_history_days} required"
)
continue
except Exception as e:
logger.error(
f"Error fetching sales data for product {product_id}: {e}",
exc_info=True
)
continue
# Product characteristics
product_characteristics = {
'lead_time_days': 7, # TODO: Get from supplier data
'shelf_life_days': 30 if product.is_perishable else 365,
'perishable': product.is_perishable
}
# Run optimization
results = await orchestrator.optimize_and_post_insights(
tenant_id=tenant_id,
inventory_product_id=product_id,
demand_history=demand_history,
product_characteristics=product_characteristics,
min_history_days=request_data.min_history_days
)
# Track results
total_insights_generated += results['insights_generated']
total_insights_posted += results['insights_posted']
if results.get('cost_savings'):
total_cost_savings += results['cost_savings']
insights_by_product[product_id] = {
'product_name': product.name,
'insights_posted': results['insights_posted'],
'optimal_safety_stock': results.get('optimal_safety_stock'),
'cost_savings': results.get('cost_savings', 0.0)
}
logger.info(
f"Product {product_id} optimization complete",
insights_posted=results['insights_posted'],
cost_savings=results.get('cost_savings', 0)
)
except Exception as e:
error_msg = f"Error optimizing product {product_id}: {str(e)}"
logger.error(error_msg, exc_info=True)
errors.append(error_msg)
# Close orchestrator
await orchestrator.close()
# Build response
response = SafetyStockOptimizationResponse(
success=total_insights_posted > 0,
message=f"Successfully optimized {len(products)} products, generated {total_insights_posted} insights",
tenant_id=tenant_id,
products_optimized=len(products),
total_insights_generated=total_insights_generated,
total_insights_posted=total_insights_posted,
total_cost_savings=round(total_cost_savings, 2),
insights_by_product=insights_by_product,
errors=errors
)
logger.info(
"ML insights safety stock optimization complete",
tenant_id=tenant_id,
total_insights=total_insights_posted,
total_savings=total_cost_savings
)
return response
except Exception as e:
logger.error(
"ML insights safety stock optimization failed",
tenant_id=tenant_id,
error=str(e),
exc_info=True
)
raise HTTPException(
status_code=500,
detail=f"Safety stock optimization failed: {str(e)}"
)
@router.get("/health")
async def ml_insights_health():
"""Health check for ML insights endpoints"""
return {
"status": "healthy",
"service": "inventory-ml-insights",
"endpoints": [
"POST /ml/insights/optimize-safety-stock"
]
}

View File

@@ -26,7 +26,8 @@ from app.api import (
analytics,
sustainability,
internal_demo,
audit
audit,
ml_insights
)
@@ -137,6 +138,7 @@ service.add_router(dashboard.router)
service.add_router(analytics.router)
service.add_router(sustainability.router)
service.add_router(internal_demo.router)
service.add_router(ml_insights.router) # ML insights endpoint
if __name__ == "__main__":

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)

View File

@@ -31,7 +31,17 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
create_data['tenant_id'] = tenant_id
# Handle product_type enum conversion
product_type_value = create_data.get('product_type', 'ingredient')
product_type_value = create_data.get('product_type')
# Log warning if product_type is missing (should be provided by frontend)
if not product_type_value:
logger.warning(
"product_type not provided, defaulting to 'ingredient'",
ingredient_name=create_data.get('name'),
tenant_id=tenant_id
)
product_type_value = 'ingredient'
if 'product_type' in create_data:
from app.models.inventory import ProductType
try:
@@ -43,10 +53,20 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
break
else:
# If not found, default to INGREDIENT
logger.warning(
"Invalid product_type value, defaulting to INGREDIENT",
invalid_value=product_type_value,
tenant_id=tenant_id
)
create_data['product_type'] = ProductType.INGREDIENT
# If it's already an enum, keep it
except Exception:
except Exception as e:
# Fallback to INGREDIENT if any issues
logger.error(
"Error converting product_type to enum, defaulting to INGREDIENT",
error=str(e),
tenant_id=tenant_id
)
create_data['product_type'] = ProductType.INGREDIENT
# Handle category mapping based on product type