Improve AI logic
This commit is contained in:
@@ -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",
|
||||
|
||||
297
services/inventory/app/api/ml_insights.py
Normal file
297
services/inventory/app/api/ml_insights.py
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
350
services/inventory/app/ml/safety_stock_insights_orchestrator.py
Normal file
350
services/inventory/app/ml/safety_stock_insights_orchestrator.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Safety Stock Insights Orchestrator
|
||||
Coordinates safety stock optimization and insight posting
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Any, Optional
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add shared clients to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||
from shared.clients.ai_insights_client import AIInsightsClient
|
||||
|
||||
from app.ml.safety_stock_optimizer import SafetyStockOptimizer
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SafetyStockInsightsOrchestrator:
|
||||
"""
|
||||
Orchestrates safety stock optimization and insight generation workflow.
|
||||
|
||||
Workflow:
|
||||
1. Optimize safety stock from demand history and cost parameters
|
||||
2. Generate insights comparing optimal vs hardcoded approach
|
||||
3. Post insights to AI Insights Service
|
||||
4. Provide optimized safety stock levels for inventory management
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ai_insights_base_url: str = "http://ai-insights-service:8000"
|
||||
):
|
||||
self.optimizer = SafetyStockOptimizer()
|
||||
self.ai_insights_client = AIInsightsClient(ai_insights_base_url)
|
||||
|
||||
async def optimize_and_post_insights(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
demand_history: pd.DataFrame,
|
||||
product_characteristics: Dict[str, Any],
|
||||
cost_parameters: Optional[Dict[str, float]] = None,
|
||||
supplier_reliability: Optional[float] = None,
|
||||
min_history_days: int = 90
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Complete workflow: Optimize safety stock and post insights.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
inventory_product_id: Product identifier
|
||||
demand_history: Historical demand data
|
||||
product_characteristics: Product properties
|
||||
cost_parameters: Optional cost parameters
|
||||
supplier_reliability: Optional supplier on-time rate
|
||||
min_history_days: Minimum days of history required
|
||||
|
||||
Returns:
|
||||
Workflow results with optimization and posted insights
|
||||
"""
|
||||
logger.info(
|
||||
"Starting safety stock optimization workflow",
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
history_days=len(demand_history)
|
||||
)
|
||||
|
||||
# Step 1: Optimize safety stock
|
||||
optimization_results = await self.optimizer.optimize_safety_stock(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
demand_history=demand_history,
|
||||
product_characteristics=product_characteristics,
|
||||
cost_parameters=cost_parameters,
|
||||
supplier_reliability=supplier_reliability,
|
||||
min_history_days=min_history_days
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Safety stock optimization complete",
|
||||
inventory_product_id=inventory_product_id,
|
||||
optimal_stock=optimization_results.get('optimal_result', {}).get('safety_stock'),
|
||||
insights_generated=len(optimization_results.get('insights', []))
|
||||
)
|
||||
|
||||
# Step 2: Enrich insights with tenant_id and product context
|
||||
enriched_insights = self._enrich_insights(
|
||||
optimization_results.get('insights', []),
|
||||
tenant_id,
|
||||
inventory_product_id
|
||||
)
|
||||
|
||||
# Step 3: Post insights to AI Insights Service
|
||||
if enriched_insights:
|
||||
post_results = await self.ai_insights_client.create_insights_bulk(
|
||||
tenant_id=UUID(tenant_id),
|
||||
insights=enriched_insights
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Safety stock insights posted to AI Insights Service",
|
||||
inventory_product_id=inventory_product_id,
|
||||
total=post_results['total'],
|
||||
successful=post_results['successful'],
|
||||
failed=post_results['failed']
|
||||
)
|
||||
else:
|
||||
post_results = {'total': 0, 'successful': 0, 'failed': 0}
|
||||
logger.info("No insights to post for product", inventory_product_id=inventory_product_id)
|
||||
|
||||
# Step 4: Return comprehensive results
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'optimized_at': optimization_results['optimized_at'],
|
||||
'history_days': optimization_results['history_days'],
|
||||
'optimal_safety_stock': optimization_results.get('optimal_result', {}).get('safety_stock'),
|
||||
'optimal_service_level': optimization_results.get('optimal_result', {}).get('service_level'),
|
||||
'cost_savings': optimization_results.get('comparison', {}).get('annual_holding_cost_savings'),
|
||||
'insights_generated': len(enriched_insights),
|
||||
'insights_posted': post_results['successful'],
|
||||
'insights_failed': post_results['failed'],
|
||||
'created_insights': post_results.get('created_insights', [])
|
||||
}
|
||||
|
||||
def _enrich_insights(
|
||||
self,
|
||||
insights: List[Dict[str, Any]],
|
||||
tenant_id: str,
|
||||
inventory_product_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Enrich insights with required fields for AI Insights Service.
|
||||
|
||||
Args:
|
||||
insights: Raw insights from optimizer
|
||||
tenant_id: Tenant identifier
|
||||
inventory_product_id: Product identifier
|
||||
|
||||
Returns:
|
||||
Enriched insights ready for posting
|
||||
"""
|
||||
enriched = []
|
||||
|
||||
for insight in insights:
|
||||
# Add required tenant_id
|
||||
enriched_insight = insight.copy()
|
||||
enriched_insight['tenant_id'] = tenant_id
|
||||
|
||||
# Add product context to metrics
|
||||
if 'metrics_json' not in enriched_insight:
|
||||
enriched_insight['metrics_json'] = {}
|
||||
|
||||
enriched_insight['metrics_json']['inventory_product_id'] = inventory_product_id
|
||||
|
||||
# Add source metadata
|
||||
enriched_insight['source_service'] = 'inventory'
|
||||
enriched_insight['source_model'] = 'safety_stock_optimizer'
|
||||
enriched_insight['detected_at'] = datetime.utcnow().isoformat()
|
||||
|
||||
enriched.append(enriched_insight)
|
||||
|
||||
return enriched
|
||||
|
||||
async def optimize_all_products(
|
||||
self,
|
||||
tenant_id: str,
|
||||
products_data: Dict[str, Dict[str, Any]],
|
||||
min_history_days: int = 90
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Optimize safety stock for all products for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
products_data: Dict of {inventory_product_id: {
|
||||
'demand_history': DataFrame,
|
||||
'product_characteristics': dict,
|
||||
'cost_parameters': dict (optional),
|
||||
'supplier_reliability': float (optional)
|
||||
}}
|
||||
min_history_days: Minimum days of history required
|
||||
|
||||
Returns:
|
||||
Comprehensive optimization results
|
||||
"""
|
||||
logger.info(
|
||||
"Optimizing safety stock for all products",
|
||||
tenant_id=tenant_id,
|
||||
products=len(products_data)
|
||||
)
|
||||
|
||||
all_results = []
|
||||
total_insights_posted = 0
|
||||
total_cost_savings = 0.0
|
||||
|
||||
# Optimize each product
|
||||
for inventory_product_id, product_data in products_data.items():
|
||||
try:
|
||||
results = await self.optimize_and_post_insights(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
demand_history=product_data['demand_history'],
|
||||
product_characteristics=product_data['product_characteristics'],
|
||||
cost_parameters=product_data.get('cost_parameters'),
|
||||
supplier_reliability=product_data.get('supplier_reliability'),
|
||||
min_history_days=min_history_days
|
||||
)
|
||||
|
||||
all_results.append(results)
|
||||
total_insights_posted += results['insights_posted']
|
||||
|
||||
if results.get('cost_savings'):
|
||||
total_cost_savings += results['cost_savings']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error optimizing product",
|
||||
inventory_product_id=inventory_product_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Generate summary insight
|
||||
if total_cost_savings > 0:
|
||||
summary_insight = self._generate_portfolio_summary_insight(
|
||||
tenant_id, all_results, total_cost_savings
|
||||
)
|
||||
|
||||
if summary_insight:
|
||||
enriched_summary = self._enrich_insights(
|
||||
[summary_insight], tenant_id, 'all_products'
|
||||
)
|
||||
|
||||
post_results = await self.ai_insights_client.create_insights_bulk(
|
||||
tenant_id=UUID(tenant_id),
|
||||
insights=enriched_summary
|
||||
)
|
||||
|
||||
total_insights_posted += post_results['successful']
|
||||
|
||||
logger.info(
|
||||
"All products optimization complete",
|
||||
tenant_id=tenant_id,
|
||||
products_optimized=len(all_results),
|
||||
total_insights_posted=total_insights_posted,
|
||||
total_annual_savings=total_cost_savings
|
||||
)
|
||||
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'optimized_at': datetime.utcnow().isoformat(),
|
||||
'products_optimized': len(all_results),
|
||||
'product_results': all_results,
|
||||
'total_insights_posted': total_insights_posted,
|
||||
'total_annual_cost_savings': round(total_cost_savings, 2)
|
||||
}
|
||||
|
||||
def _generate_portfolio_summary_insight(
|
||||
self,
|
||||
tenant_id: str,
|
||||
all_results: List[Dict[str, Any]],
|
||||
total_cost_savings: float
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Generate portfolio-level summary insight.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
all_results: All product optimization results
|
||||
total_cost_savings: Total annual cost savings
|
||||
|
||||
Returns:
|
||||
Summary insight or None
|
||||
"""
|
||||
if total_cost_savings < 100: # Only if meaningful savings
|
||||
return None
|
||||
|
||||
products_optimized = len(all_results)
|
||||
products_with_savings = len([r for r in all_results if r.get('cost_savings', 0) > 0])
|
||||
|
||||
return {
|
||||
'type': 'optimization',
|
||||
'priority': 'high' if total_cost_savings > 1000 else 'medium',
|
||||
'category': 'inventory',
|
||||
'title': f'Portfolio Safety Stock Optimization: €{total_cost_savings:.0f}/year Savings',
|
||||
'description': f'Optimized safety stock across {products_optimized} products. {products_with_savings} products have over-stocked inventory. Implementing optimal levels saves €{total_cost_savings:.2f} annually in holding costs while maintaining or improving service levels.',
|
||||
'impact_type': 'cost_savings',
|
||||
'impact_value': total_cost_savings,
|
||||
'impact_unit': 'euros_per_year',
|
||||
'confidence': 85,
|
||||
'metrics_json': {
|
||||
'products_optimized': products_optimized,
|
||||
'products_with_savings': products_with_savings,
|
||||
'total_annual_savings': round(total_cost_savings, 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Apply All Optimizations',
|
||||
'action': 'apply_all_safety_stock_optimizations',
|
||||
'params': {'tenant_id': tenant_id}
|
||||
},
|
||||
{
|
||||
'label': 'Review Individual Products',
|
||||
'action': 'review_safety_stock_insights',
|
||||
'params': {'tenant_id': tenant_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'inventory',
|
||||
'source_model': 'safety_stock_optimizer'
|
||||
}
|
||||
|
||||
async def get_optimal_safety_stock(
|
||||
self,
|
||||
inventory_product_id: str
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Get cached optimal safety stock for a product.
|
||||
|
||||
Args:
|
||||
inventory_product_id: Product identifier
|
||||
|
||||
Returns:
|
||||
Optimal safety stock or None if not optimized
|
||||
"""
|
||||
return self.optimizer.get_optimal_safety_stock(inventory_product_id)
|
||||
|
||||
async def get_learned_service_level(
|
||||
self,
|
||||
inventory_product_id: str
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Get learned optimal service level for a product.
|
||||
|
||||
Args:
|
||||
inventory_product_id: Product identifier
|
||||
|
||||
Returns:
|
||||
Optimal service level (0-1) or None if not learned
|
||||
"""
|
||||
return self.optimizer.get_learned_service_level(inventory_product_id)
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client connections."""
|
||||
await self.ai_insights_client.close()
|
||||
755
services/inventory/app/ml/safety_stock_optimizer.py
Normal file
755
services/inventory/app/ml/safety_stock_optimizer.py
Normal file
@@ -0,0 +1,755 @@
|
||||
"""
|
||||
Safety Stock Optimizer
|
||||
Replaces hardcoded 95% service level with learned optimal safety stock levels
|
||||
Optimizes based on product characteristics, demand variability, and cost trade-offs
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import structlog
|
||||
from datetime import datetime, timedelta
|
||||
from scipy import stats
|
||||
from scipy.optimize import minimize_scalar
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SafetyStockOptimizer:
|
||||
"""
|
||||
Optimizes safety stock levels for inventory management.
|
||||
|
||||
Current problem: Hardcoded 95% service level for all products
|
||||
Solution: Learn optimal service levels based on:
|
||||
- Product characteristics (shelf life, criticality)
|
||||
- Demand variability (coefficient of variation)
|
||||
- Cost trade-offs (holding cost vs stockout cost)
|
||||
- Historical stockout patterns
|
||||
- Supplier reliability
|
||||
|
||||
Approaches:
|
||||
1. Statistical approach: Based on demand variability and lead time
|
||||
2. Cost-based optimization: Minimize total cost (holding + stockout)
|
||||
3. Service level optimization: Product-specific target service levels
|
||||
4. Dynamic adjustment: Seasonality and trend awareness
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.optimal_stocks = {}
|
||||
self.learned_service_levels = {}
|
||||
|
||||
async def optimize_safety_stock(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
demand_history: pd.DataFrame,
|
||||
product_characteristics: Dict[str, Any],
|
||||
cost_parameters: Optional[Dict[str, float]] = None,
|
||||
supplier_reliability: Optional[float] = None,
|
||||
min_history_days: int = 90
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate optimal safety stock for a product.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
inventory_product_id: Product identifier
|
||||
demand_history: Historical demand data with columns:
|
||||
- date
|
||||
- demand_quantity
|
||||
- stockout (bool, optional)
|
||||
- lead_time_days (optional)
|
||||
product_characteristics: Product properties:
|
||||
- shelf_life_days: int
|
||||
- criticality: str (high, medium, low)
|
||||
- unit_cost: float
|
||||
- avg_daily_demand: float
|
||||
cost_parameters: Optional cost params:
|
||||
- holding_cost_per_unit_per_day: float
|
||||
- stockout_cost_per_unit: float
|
||||
supplier_reliability: Supplier on-time rate (0-1)
|
||||
min_history_days: Minimum days of history required
|
||||
|
||||
Returns:
|
||||
Dictionary with optimal safety stock and insights
|
||||
"""
|
||||
logger.info(
|
||||
"Optimizing safety stock",
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
history_days=len(demand_history)
|
||||
)
|
||||
|
||||
# Validate input
|
||||
if len(demand_history) < min_history_days:
|
||||
logger.warning(
|
||||
"Insufficient demand history",
|
||||
inventory_product_id=inventory_product_id,
|
||||
days=len(demand_history),
|
||||
required=min_history_days
|
||||
)
|
||||
return self._insufficient_data_response(
|
||||
tenant_id, inventory_product_id, product_characteristics
|
||||
)
|
||||
|
||||
# Calculate demand statistics
|
||||
demand_stats = self._calculate_demand_statistics(demand_history)
|
||||
|
||||
# Calculate optimal safety stock using multiple methods
|
||||
statistical_result = self._calculate_statistical_safety_stock(
|
||||
demand_stats,
|
||||
product_characteristics,
|
||||
supplier_reliability
|
||||
)
|
||||
|
||||
# Cost-based optimization if cost parameters provided
|
||||
if cost_parameters:
|
||||
cost_based_result = self._calculate_cost_optimal_safety_stock(
|
||||
demand_stats,
|
||||
product_characteristics,
|
||||
cost_parameters,
|
||||
demand_history
|
||||
)
|
||||
else:
|
||||
cost_based_result = None
|
||||
|
||||
# Service level optimization
|
||||
service_level_result = self._calculate_service_level_optimal_stock(
|
||||
demand_stats,
|
||||
product_characteristics,
|
||||
demand_history
|
||||
)
|
||||
|
||||
# Combine methods and select optimal
|
||||
optimal_result = self._select_optimal_safety_stock(
|
||||
statistical_result,
|
||||
cost_based_result,
|
||||
service_level_result,
|
||||
product_characteristics
|
||||
)
|
||||
|
||||
# Compare with current hardcoded approach (95% service level)
|
||||
hardcoded_result = self._calculate_hardcoded_safety_stock(
|
||||
demand_stats,
|
||||
service_level=0.95
|
||||
)
|
||||
|
||||
comparison = self._compare_with_hardcoded(
|
||||
optimal_result,
|
||||
hardcoded_result,
|
||||
cost_parameters
|
||||
)
|
||||
|
||||
# Generate insights
|
||||
insights = self._generate_safety_stock_insights(
|
||||
tenant_id,
|
||||
inventory_product_id,
|
||||
optimal_result,
|
||||
hardcoded_result,
|
||||
comparison,
|
||||
demand_stats,
|
||||
product_characteristics
|
||||
)
|
||||
|
||||
# Store optimal stock
|
||||
self.optimal_stocks[inventory_product_id] = optimal_result['safety_stock']
|
||||
self.learned_service_levels[inventory_product_id] = optimal_result['service_level']
|
||||
|
||||
logger.info(
|
||||
"Safety stock optimization complete",
|
||||
inventory_product_id=inventory_product_id,
|
||||
optimal_stock=optimal_result['safety_stock'],
|
||||
optimal_service_level=optimal_result['service_level'],
|
||||
improvement_vs_hardcoded=comparison.get('cost_savings_pct', 0)
|
||||
)
|
||||
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'optimized_at': datetime.utcnow().isoformat(),
|
||||
'history_days': len(demand_history),
|
||||
'demand_stats': demand_stats,
|
||||
'optimal_result': optimal_result,
|
||||
'hardcoded_result': hardcoded_result,
|
||||
'comparison': comparison,
|
||||
'insights': insights
|
||||
}
|
||||
|
||||
def _calculate_demand_statistics(
|
||||
self,
|
||||
demand_history: pd.DataFrame
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate comprehensive demand statistics.
|
||||
|
||||
Args:
|
||||
demand_history: Historical demand data
|
||||
|
||||
Returns:
|
||||
Dictionary of demand statistics
|
||||
"""
|
||||
# Ensure date column
|
||||
if 'date' not in demand_history.columns:
|
||||
demand_history = demand_history.copy()
|
||||
demand_history['date'] = pd.to_datetime(demand_history.index)
|
||||
|
||||
demand_history['date'] = pd.to_datetime(demand_history['date'])
|
||||
|
||||
# Basic statistics
|
||||
mean_demand = demand_history['demand_quantity'].mean()
|
||||
std_demand = demand_history['demand_quantity'].std()
|
||||
cv_demand = std_demand / mean_demand if mean_demand > 0 else 0
|
||||
|
||||
# Lead time statistics (if available)
|
||||
if 'lead_time_days' in demand_history.columns:
|
||||
mean_lead_time = demand_history['lead_time_days'].mean()
|
||||
std_lead_time = demand_history['lead_time_days'].std()
|
||||
else:
|
||||
mean_lead_time = 3.0 # Default assumption
|
||||
std_lead_time = 0.5
|
||||
|
||||
# Stockout rate (if available)
|
||||
if 'stockout' in demand_history.columns:
|
||||
stockout_rate = demand_history['stockout'].mean()
|
||||
stockout_frequency = demand_history['stockout'].sum()
|
||||
else:
|
||||
stockout_rate = 0.05 # Assume 5% if not tracked
|
||||
stockout_frequency = 0
|
||||
|
||||
# Demand distribution characteristics
|
||||
skewness = demand_history['demand_quantity'].skew()
|
||||
kurtosis = demand_history['demand_quantity'].kurtosis()
|
||||
|
||||
# Recent trend (last 30 days vs overall)
|
||||
if len(demand_history) >= 60:
|
||||
recent_mean = demand_history.tail(30)['demand_quantity'].mean()
|
||||
trend = (recent_mean - mean_demand) / mean_demand if mean_demand > 0 else 0
|
||||
else:
|
||||
trend = 0
|
||||
|
||||
return {
|
||||
'mean_demand': float(mean_demand),
|
||||
'std_demand': float(std_demand),
|
||||
'cv_demand': float(cv_demand),
|
||||
'min_demand': float(demand_history['demand_quantity'].min()),
|
||||
'max_demand': float(demand_history['demand_quantity'].max()),
|
||||
'mean_lead_time': float(mean_lead_time),
|
||||
'std_lead_time': float(std_lead_time),
|
||||
'stockout_rate': float(stockout_rate),
|
||||
'stockout_frequency': int(stockout_frequency),
|
||||
'skewness': float(skewness),
|
||||
'kurtosis': float(kurtosis),
|
||||
'trend': float(trend),
|
||||
'data_points': int(len(demand_history))
|
||||
}
|
||||
|
||||
def _calculate_statistical_safety_stock(
|
||||
self,
|
||||
demand_stats: Dict[str, float],
|
||||
product_characteristics: Dict[str, Any],
|
||||
supplier_reliability: Optional[float] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate safety stock using statistical approach (Classic formula).
|
||||
|
||||
Formula: SS = Z * sqrt(LT * σ_d² + d_avg² * σ_LT²)
|
||||
Where:
|
||||
- Z: Z-score for desired service level
|
||||
- LT: Mean lead time
|
||||
- σ_d: Standard deviation of demand
|
||||
- d_avg: Average demand
|
||||
- σ_LT: Standard deviation of lead time
|
||||
"""
|
||||
# Determine target service level based on product criticality
|
||||
criticality = product_characteristics.get('criticality', 'medium').lower()
|
||||
|
||||
if criticality == 'high':
|
||||
target_service_level = 0.98 # 98% for critical products
|
||||
elif criticality == 'medium':
|
||||
target_service_level = 0.95 # 95% for medium
|
||||
else:
|
||||
target_service_level = 0.90 # 90% for low criticality
|
||||
|
||||
# Adjust for supplier reliability
|
||||
if supplier_reliability is not None and supplier_reliability < 0.9:
|
||||
# Less reliable suppliers need higher safety stock
|
||||
target_service_level = min(0.99, target_service_level + 0.03)
|
||||
|
||||
# Calculate Z-score for target service level
|
||||
z_score = stats.norm.ppf(target_service_level)
|
||||
|
||||
# Calculate safety stock
|
||||
mean_demand = demand_stats['mean_demand']
|
||||
std_demand = demand_stats['std_demand']
|
||||
mean_lead_time = demand_stats['mean_lead_time']
|
||||
std_lead_time = demand_stats['std_lead_time']
|
||||
|
||||
# Safety stock formula
|
||||
variance_component = (
|
||||
mean_lead_time * (std_demand ** 2) +
|
||||
(mean_demand ** 2) * (std_lead_time ** 2)
|
||||
)
|
||||
|
||||
safety_stock = z_score * np.sqrt(variance_component)
|
||||
|
||||
# Ensure non-negative
|
||||
safety_stock = max(0, safety_stock)
|
||||
|
||||
return {
|
||||
'method': 'statistical',
|
||||
'safety_stock': round(safety_stock, 2),
|
||||
'service_level': target_service_level,
|
||||
'z_score': round(z_score, 2),
|
||||
'rationale': f'Based on {target_service_level*100:.0f}% service level for {criticality} criticality product'
|
||||
}
|
||||
|
||||
def _calculate_cost_optimal_safety_stock(
|
||||
self,
|
||||
demand_stats: Dict[str, float],
|
||||
product_characteristics: Dict[str, Any],
|
||||
cost_parameters: Dict[str, float],
|
||||
demand_history: pd.DataFrame
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate safety stock that minimizes total cost (holding + stockout).
|
||||
|
||||
Total Cost = (Holding Cost × Safety Stock) + (Stockout Cost × Stockout Frequency)
|
||||
"""
|
||||
holding_cost = cost_parameters.get('holding_cost_per_unit_per_day', 0.01)
|
||||
stockout_cost = cost_parameters.get('stockout_cost_per_unit', 10.0)
|
||||
|
||||
mean_demand = demand_stats['mean_demand']
|
||||
std_demand = demand_stats['std_demand']
|
||||
mean_lead_time = demand_stats['mean_lead_time']
|
||||
|
||||
def total_cost(safety_stock):
|
||||
"""Calculate total cost for given safety stock level."""
|
||||
# Holding cost (annual)
|
||||
annual_holding_cost = holding_cost * safety_stock * 365
|
||||
|
||||
# Stockout probability and expected stockouts
|
||||
# Demand during lead time follows normal distribution
|
||||
demand_during_lt_mean = mean_demand * mean_lead_time
|
||||
demand_during_lt_std = std_demand * np.sqrt(mean_lead_time)
|
||||
|
||||
# Service level achieved with this safety stock
|
||||
if demand_during_lt_std > 0:
|
||||
z_score = (safety_stock) / demand_during_lt_std
|
||||
service_level = stats.norm.cdf(z_score)
|
||||
else:
|
||||
service_level = 0.99
|
||||
|
||||
# Stockout probability
|
||||
stockout_prob = 1 - service_level
|
||||
|
||||
# Expected annual stockouts (simplified)
|
||||
orders_per_year = 365 / mean_lead_time
|
||||
expected_stockouts = stockout_prob * orders_per_year * mean_demand
|
||||
|
||||
# Stockout cost (annual)
|
||||
annual_stockout_cost = expected_stockouts * stockout_cost
|
||||
|
||||
return annual_holding_cost + annual_stockout_cost
|
||||
|
||||
# Optimize to find minimum total cost
|
||||
# Search range: 0 to 5 * mean demand during lead time
|
||||
max_search = 5 * mean_demand * mean_lead_time
|
||||
|
||||
result = minimize_scalar(
|
||||
total_cost,
|
||||
bounds=(0, max_search),
|
||||
method='bounded'
|
||||
)
|
||||
|
||||
optimal_safety_stock = result.x
|
||||
optimal_cost = result.fun
|
||||
|
||||
# Calculate achieved service level
|
||||
demand_during_lt_std = std_demand * np.sqrt(mean_lead_time)
|
||||
if demand_during_lt_std > 0:
|
||||
z_score = optimal_safety_stock / demand_during_lt_std
|
||||
achieved_service_level = stats.norm.cdf(z_score)
|
||||
else:
|
||||
achieved_service_level = 0.99
|
||||
|
||||
return {
|
||||
'method': 'cost_optimization',
|
||||
'safety_stock': round(optimal_safety_stock, 2),
|
||||
'service_level': round(achieved_service_level, 4),
|
||||
'annual_total_cost': round(optimal_cost, 2),
|
||||
'rationale': f'Minimizes total cost (holding + stockout): €{optimal_cost:.2f}/year'
|
||||
}
|
||||
|
||||
def _calculate_service_level_optimal_stock(
|
||||
self,
|
||||
demand_stats: Dict[str, float],
|
||||
product_characteristics: Dict[str, Any],
|
||||
demand_history: pd.DataFrame
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate safety stock based on empirical service level optimization.
|
||||
|
||||
Uses historical stockout data to find optimal service level.
|
||||
"""
|
||||
# If we have stockout history, learn from it
|
||||
if 'stockout' in demand_history.columns and demand_history['stockout'].sum() > 0:
|
||||
current_stockout_rate = demand_stats['stockout_rate']
|
||||
|
||||
# Target: Reduce stockouts by 50% or achieve 95%, whichever is higher
|
||||
target_stockout_rate = min(current_stockout_rate * 0.5, 0.05)
|
||||
target_service_level = 1 - target_stockout_rate
|
||||
|
||||
else:
|
||||
# No stockout data, use criticality-based default
|
||||
criticality = product_characteristics.get('criticality', 'medium').lower()
|
||||
target_service_level = {
|
||||
'high': 0.98,
|
||||
'medium': 0.95,
|
||||
'low': 0.90
|
||||
}.get(criticality, 0.95)
|
||||
|
||||
# Calculate safety stock for target service level
|
||||
z_score = stats.norm.ppf(target_service_level)
|
||||
mean_demand = demand_stats['mean_demand']
|
||||
std_demand = demand_stats['std_demand']
|
||||
mean_lead_time = demand_stats['mean_lead_time']
|
||||
|
||||
safety_stock = z_score * std_demand * np.sqrt(mean_lead_time)
|
||||
safety_stock = max(0, safety_stock)
|
||||
|
||||
return {
|
||||
'method': 'service_level_optimization',
|
||||
'safety_stock': round(safety_stock, 2),
|
||||
'service_level': target_service_level,
|
||||
'rationale': f'Achieves {target_service_level*100:.0f}% service level based on historical performance'
|
||||
}
|
||||
|
||||
def _select_optimal_safety_stock(
|
||||
self,
|
||||
statistical_result: Dict[str, Any],
|
||||
cost_based_result: Optional[Dict[str, Any]],
|
||||
service_level_result: Dict[str, Any],
|
||||
product_characteristics: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Select optimal safety stock from multiple methods.
|
||||
|
||||
Priority:
|
||||
1. Cost-based if available and product value is high
|
||||
2. Statistical for general case
|
||||
3. Service level as validation
|
||||
"""
|
||||
# If cost data available and product is valuable, use cost optimization
|
||||
if cost_based_result and product_characteristics.get('unit_cost', 0) > 5:
|
||||
selected = cost_based_result
|
||||
logger.info("Selected cost-based safety stock (high-value product)")
|
||||
|
||||
# Otherwise use statistical approach
|
||||
else:
|
||||
selected = statistical_result
|
||||
logger.info("Selected statistical safety stock")
|
||||
|
||||
# Add shelf life constraint
|
||||
shelf_life = product_characteristics.get('shelf_life_days')
|
||||
if shelf_life:
|
||||
max_safe_stock = product_characteristics.get('avg_daily_demand', 0) * (shelf_life * 0.5)
|
||||
if selected['safety_stock'] > max_safe_stock:
|
||||
logger.warning(
|
||||
"Safety stock exceeds shelf life constraint",
|
||||
calculated=selected['safety_stock'],
|
||||
max_allowed=max_safe_stock
|
||||
)
|
||||
selected['safety_stock'] = round(max_safe_stock, 2)
|
||||
selected['constrained_by'] = 'shelf_life'
|
||||
|
||||
return selected
|
||||
|
||||
def _calculate_hardcoded_safety_stock(
|
||||
self,
|
||||
demand_stats: Dict[str, float],
|
||||
service_level: float = 0.95
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate safety stock using current hardcoded 95% service level.
|
||||
|
||||
Args:
|
||||
demand_stats: Demand statistics
|
||||
service_level: Hardcoded service level (default 0.95)
|
||||
|
||||
Returns:
|
||||
Safety stock result with hardcoded approach
|
||||
"""
|
||||
z_score = stats.norm.ppf(service_level)
|
||||
mean_demand = demand_stats['mean_demand']
|
||||
std_demand = demand_stats['std_demand']
|
||||
mean_lead_time = demand_stats['mean_lead_time']
|
||||
|
||||
safety_stock = z_score * std_demand * np.sqrt(mean_lead_time)
|
||||
safety_stock = max(0, safety_stock)
|
||||
|
||||
return {
|
||||
'method': 'hardcoded_95_service_level',
|
||||
'safety_stock': round(safety_stock, 2),
|
||||
'service_level': service_level,
|
||||
'rationale': 'Current hardcoded 95% service level for all products'
|
||||
}
|
||||
|
||||
def _compare_with_hardcoded(
|
||||
self,
|
||||
optimal_result: Dict[str, Any],
|
||||
hardcoded_result: Dict[str, Any],
|
||||
cost_parameters: Optional[Dict[str, float]]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare optimal safety stock with hardcoded approach.
|
||||
|
||||
Args:
|
||||
optimal_result: Optimal safety stock result
|
||||
hardcoded_result: Hardcoded approach result
|
||||
cost_parameters: Optional cost parameters for savings calculation
|
||||
|
||||
Returns:
|
||||
Comparison metrics
|
||||
"""
|
||||
optimal_stock = optimal_result['safety_stock']
|
||||
hardcoded_stock = hardcoded_result['safety_stock']
|
||||
|
||||
stock_difference = optimal_stock - hardcoded_stock
|
||||
stock_difference_pct = (stock_difference / hardcoded_stock * 100) if hardcoded_stock > 0 else 0
|
||||
|
||||
comparison = {
|
||||
'stock_difference': round(stock_difference, 2),
|
||||
'stock_difference_pct': round(stock_difference_pct, 2),
|
||||
'optimal_service_level': optimal_result['service_level'],
|
||||
'hardcoded_service_level': hardcoded_result['service_level'],
|
||||
'service_level_difference': round(
|
||||
(optimal_result['service_level'] - hardcoded_result['service_level']) * 100, 2
|
||||
)
|
||||
}
|
||||
|
||||
# Calculate cost savings if cost data available
|
||||
if cost_parameters:
|
||||
holding_cost = cost_parameters.get('holding_cost_per_unit_per_day', 0.01)
|
||||
annual_holding_savings = stock_difference * holding_cost * 365
|
||||
|
||||
comparison['annual_holding_cost_savings'] = round(annual_holding_savings, 2)
|
||||
if hardcoded_stock > 0:
|
||||
comparison['cost_savings_pct'] = round(
|
||||
(annual_holding_savings / (hardcoded_stock * holding_cost * 365)) * 100, 2
|
||||
)
|
||||
|
||||
return comparison
|
||||
|
||||
def _generate_safety_stock_insights(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
optimal_result: Dict[str, Any],
|
||||
hardcoded_result: Dict[str, Any],
|
||||
comparison: Dict[str, Any],
|
||||
demand_stats: Dict[str, float],
|
||||
product_characteristics: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate actionable insights from safety stock optimization.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
inventory_product_id: Product ID
|
||||
optimal_result: Optimal safety stock result
|
||||
hardcoded_result: Hardcoded result
|
||||
comparison: Comparison metrics
|
||||
demand_stats: Demand statistics
|
||||
product_characteristics: Product characteristics
|
||||
|
||||
Returns:
|
||||
List of insights
|
||||
"""
|
||||
insights = []
|
||||
|
||||
stock_diff_pct = comparison['stock_difference_pct']
|
||||
|
||||
# Insight 1: Over-stocking reduction opportunity
|
||||
if stock_diff_pct < -10: # Optimal is >10% lower
|
||||
cost_savings = comparison.get('annual_holding_cost_savings', 0)
|
||||
|
||||
insights.append({
|
||||
'type': 'optimization',
|
||||
'priority': 'high' if abs(stock_diff_pct) > 25 else 'medium',
|
||||
'category': 'inventory',
|
||||
'title': f'Reduce Safety Stock by {abs(stock_diff_pct):.0f}%',
|
||||
'description': f'Product {inventory_product_id} is over-stocked. Optimal safety stock is {optimal_result["safety_stock"]:.1f} units vs current {hardcoded_result["safety_stock"]:.1f}. Reducing to optimal level saves €{abs(cost_savings):.2f}/year in holding costs while maintaining {optimal_result["service_level"]*100:.1f}% service level.',
|
||||
'impact_type': 'cost_savings',
|
||||
'impact_value': abs(cost_savings),
|
||||
'impact_unit': 'euros_per_year',
|
||||
'confidence': 85,
|
||||
'metrics_json': {
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'current_safety_stock': round(hardcoded_result['safety_stock'], 2),
|
||||
'optimal_safety_stock': round(optimal_result['safety_stock'], 2),
|
||||
'reduction_pct': round(abs(stock_diff_pct), 2),
|
||||
'annual_savings': round(abs(cost_savings), 2),
|
||||
'optimal_service_level': round(optimal_result['service_level'] * 100, 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Update Safety Stock',
|
||||
'action': 'update_safety_stock',
|
||||
'params': {
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'new_safety_stock': round(optimal_result['safety_stock'], 2)
|
||||
}
|
||||
}
|
||||
],
|
||||
'source_service': 'inventory',
|
||||
'source_model': 'safety_stock_optimizer'
|
||||
})
|
||||
|
||||
# Insight 2: Under-stocking risk
|
||||
elif stock_diff_pct > 10: # Optimal is >10% higher
|
||||
insights.append({
|
||||
'type': 'alert',
|
||||
'priority': 'high' if stock_diff_pct > 25 else 'medium',
|
||||
'category': 'inventory',
|
||||
'title': f'Increase Safety Stock by {stock_diff_pct:.0f}%',
|
||||
'description': f'Product {inventory_product_id} safety stock is too low. Current {hardcoded_result["safety_stock"]:.1f} units provides only {hardcoded_result["service_level"]*100:.0f}% service level. Increase to {optimal_result["safety_stock"]:.1f} for optimal {optimal_result["service_level"]*100:.1f}% service level.',
|
||||
'impact_type': 'stockout_risk_reduction',
|
||||
'impact_value': stock_diff_pct,
|
||||
'impact_unit': 'percentage',
|
||||
'confidence': 85,
|
||||
'metrics_json': {
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'current_safety_stock': round(hardcoded_result['safety_stock'], 2),
|
||||
'optimal_safety_stock': round(optimal_result['safety_stock'], 2),
|
||||
'increase_pct': round(stock_diff_pct, 2),
|
||||
'current_service_level': round(hardcoded_result['service_level'] * 100, 2),
|
||||
'optimal_service_level': round(optimal_result['service_level'] * 100, 2),
|
||||
'historical_stockout_rate': round(demand_stats['stockout_rate'] * 100, 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Update Safety Stock',
|
||||
'action': 'update_safety_stock',
|
||||
'params': {
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'new_safety_stock': round(optimal_result['safety_stock'], 2)
|
||||
}
|
||||
}
|
||||
],
|
||||
'source_service': 'inventory',
|
||||
'source_model': 'safety_stock_optimizer'
|
||||
})
|
||||
|
||||
# Insight 3: High demand variability
|
||||
if demand_stats['cv_demand'] > 0.5: # Coefficient of variation > 0.5
|
||||
insights.append({
|
||||
'type': 'insight',
|
||||
'priority': 'medium',
|
||||
'category': 'inventory',
|
||||
'title': f'High Demand Variability Detected',
|
||||
'description': f'Product {inventory_product_id} has high demand variability (CV={demand_stats["cv_demand"]:.2f}). This increases safety stock requirements. Consider demand smoothing strategies or more frequent orders.',
|
||||
'impact_type': 'operational_insight',
|
||||
'impact_value': demand_stats['cv_demand'],
|
||||
'impact_unit': 'coefficient_of_variation',
|
||||
'confidence': 90,
|
||||
'metrics_json': {
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'cv_demand': round(demand_stats['cv_demand'], 2),
|
||||
'mean_demand': round(demand_stats['mean_demand'], 2),
|
||||
'std_demand': round(demand_stats['std_demand'], 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Review Demand Patterns',
|
||||
'action': 'analyze_demand_patterns',
|
||||
'params': {'inventory_product_id': inventory_product_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'inventory',
|
||||
'source_model': 'safety_stock_optimizer'
|
||||
})
|
||||
|
||||
# Insight 4: Frequent stockouts
|
||||
if demand_stats['stockout_rate'] > 0.1: # More than 10% stockout rate
|
||||
insights.append({
|
||||
'type': 'alert',
|
||||
'priority': 'critical' if demand_stats['stockout_rate'] > 0.2 else 'high',
|
||||
'category': 'inventory',
|
||||
'title': f'Frequent Stockouts: {demand_stats["stockout_rate"]*100:.1f}%',
|
||||
'description': f'Product {inventory_product_id} experiences frequent stockouts ({demand_stats["stockout_rate"]*100:.1f}% of days). Optimal safety stock of {optimal_result["safety_stock"]:.1f} units should reduce this significantly.',
|
||||
'impact_type': 'stockout_frequency',
|
||||
'impact_value': demand_stats['stockout_rate'] * 100,
|
||||
'impact_unit': 'percentage',
|
||||
'confidence': 95,
|
||||
'metrics_json': {
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'stockout_rate': round(demand_stats['stockout_rate'] * 100, 2),
|
||||
'stockout_frequency': demand_stats['stockout_frequency'],
|
||||
'optimal_safety_stock': round(optimal_result['safety_stock'], 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'URGENT: Update Safety Stock',
|
||||
'action': 'update_safety_stock',
|
||||
'params': {
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'new_safety_stock': round(optimal_result['safety_stock'], 2)
|
||||
}
|
||||
},
|
||||
{
|
||||
'label': 'Review Supplier Reliability',
|
||||
'action': 'review_supplier',
|
||||
'params': {'inventory_product_id': inventory_product_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'inventory',
|
||||
'source_model': 'safety_stock_optimizer'
|
||||
})
|
||||
|
||||
return insights
|
||||
|
||||
def _insufficient_data_response(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
product_characteristics: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Return response when insufficient data available."""
|
||||
# Use simple heuristic based on criticality
|
||||
criticality = product_characteristics.get('criticality', 'medium').lower()
|
||||
avg_daily_demand = product_characteristics.get('avg_daily_demand', 10)
|
||||
|
||||
# Simple rule: 7 days of demand for high, 5 for medium, 3 for low
|
||||
safety_stock_days = {'high': 7, 'medium': 5, 'low': 3}.get(criticality, 5)
|
||||
fallback_safety_stock = avg_daily_demand * safety_stock_days
|
||||
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'inventory_product_id': inventory_product_id,
|
||||
'optimized_at': datetime.utcnow().isoformat(),
|
||||
'history_days': 0,
|
||||
'demand_stats': {},
|
||||
'optimal_result': {
|
||||
'method': 'fallback_heuristic',
|
||||
'safety_stock': round(fallback_safety_stock, 2),
|
||||
'service_level': 0.95,
|
||||
'rationale': f'Insufficient data. Using {safety_stock_days} days of demand for {criticality} criticality.'
|
||||
},
|
||||
'hardcoded_result': None,
|
||||
'comparison': {},
|
||||
'insights': []
|
||||
}
|
||||
|
||||
def get_optimal_safety_stock(self, inventory_product_id: str) -> Optional[float]:
|
||||
"""Get cached optimal safety stock for a product."""
|
||||
return self.optimal_stocks.get(inventory_product_id)
|
||||
|
||||
def get_learned_service_level(self, inventory_product_id: str) -> Optional[float]:
|
||||
"""Get learned optimal service level for a product."""
|
||||
return self.learned_service_levels.get(inventory_product_id)
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ alembic==1.17.0
|
||||
# Data processing
|
||||
pandas==2.2.3
|
||||
numpy==2.2.2
|
||||
scipy==1.15.1
|
||||
|
||||
# HTTP clients
|
||||
httpx==0.28.1
|
||||
|
||||
604
services/inventory/tests/test_safety_stock_optimizer.py
Normal file
604
services/inventory/tests/test_safety_stock_optimizer.py
Normal file
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
Tests for Safety Stock Optimizer
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
from app.ml.safety_stock_optimizer import SafetyStockOptimizer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stable_demand_history():
|
||||
"""Generate demand history with stable, predictable demand."""
|
||||
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')
|
||||
|
||||
# Stable demand: mean=100, low variance
|
||||
demands = np.random.normal(100, 10, len(dates))
|
||||
demands = np.maximum(demands, 0) # No negative demand
|
||||
|
||||
data = {
|
||||
'date': dates,
|
||||
'demand_quantity': demands,
|
||||
'lead_time_days': [3] * len(dates),
|
||||
'stockout': [False] * len(dates)
|
||||
}
|
||||
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def variable_demand_history():
|
||||
"""Generate demand history with high variability."""
|
||||
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')
|
||||
|
||||
# Variable demand: mean=100, high variance
|
||||
demands = np.random.normal(100, 40, len(dates))
|
||||
demands = np.maximum(demands, 0)
|
||||
|
||||
# Add some stockouts (10%)
|
||||
stockouts = np.random.random(len(dates)) < 0.1
|
||||
|
||||
data = {
|
||||
'date': dates,
|
||||
'demand_quantity': demands,
|
||||
'lead_time_days': np.random.normal(3, 1, len(dates)),
|
||||
'stockout': stockouts
|
||||
}
|
||||
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def high_criticality_product():
|
||||
"""Product characteristics for high criticality product."""
|
||||
return {
|
||||
'shelf_life_days': 7,
|
||||
'criticality': 'high',
|
||||
'unit_cost': 15.0,
|
||||
'avg_daily_demand': 100
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def low_criticality_product():
|
||||
"""Product characteristics for low criticality product."""
|
||||
return {
|
||||
'shelf_life_days': 30,
|
||||
'criticality': 'low',
|
||||
'unit_cost': 2.0,
|
||||
'avg_daily_demand': 50
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cost_parameters():
|
||||
"""Standard cost parameters."""
|
||||
return {
|
||||
'holding_cost_per_unit_per_day': 0.01,
|
||||
'stockout_cost_per_unit': 10.0
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optimize_stable_demand(stable_demand_history, high_criticality_product):
|
||||
"""Test optimization with stable demand."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='stable-product',
|
||||
demand_history=stable_demand_history,
|
||||
product_characteristics=high_criticality_product,
|
||||
min_history_days=90
|
||||
)
|
||||
|
||||
# Check structure
|
||||
assert 'tenant_id' in results
|
||||
assert 'inventory_product_id' in results
|
||||
assert 'optimal_result' in results
|
||||
assert 'hardcoded_result' in results
|
||||
assert 'comparison' in results
|
||||
assert 'insights' in results
|
||||
|
||||
# Stable demand should have lower safety stock
|
||||
optimal = results['optimal_result']
|
||||
assert optimal['safety_stock'] > 0
|
||||
assert 0.90 <= optimal['service_level'] <= 0.99
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optimize_variable_demand(variable_demand_history, high_criticality_product):
|
||||
"""Test optimization with variable demand."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='variable-product',
|
||||
demand_history=variable_demand_history,
|
||||
product_characteristics=high_criticality_product,
|
||||
min_history_days=90
|
||||
)
|
||||
|
||||
optimal = results['optimal_result']
|
||||
|
||||
# Variable demand should require higher safety stock
|
||||
assert optimal['safety_stock'] > 0
|
||||
|
||||
# Should achieve high service level for high criticality
|
||||
assert optimal['service_level'] >= 0.95
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_demand_statistics_calculation(stable_demand_history):
|
||||
"""Test demand statistics calculation."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='test-product',
|
||||
demand_history=stable_demand_history,
|
||||
product_characteristics={'criticality': 'medium', 'avg_daily_demand': 100}
|
||||
)
|
||||
|
||||
stats = results['demand_stats']
|
||||
|
||||
# Check all required statistics present
|
||||
required_stats = [
|
||||
'mean_demand', 'std_demand', 'cv_demand',
|
||||
'min_demand', 'max_demand',
|
||||
'mean_lead_time', 'std_lead_time',
|
||||
'stockout_rate', 'data_points'
|
||||
]
|
||||
|
||||
for stat in required_stats:
|
||||
assert stat in stats, f"Missing statistic: {stat}"
|
||||
|
||||
# Check values are reasonable
|
||||
assert stats['mean_demand'] > 0
|
||||
assert stats['std_demand'] >= 0
|
||||
assert 0 <= stats['cv_demand'] <= 2
|
||||
assert stats['mean_lead_time'] > 0
|
||||
assert 0 <= stats['stockout_rate'] <= 1
|
||||
assert stats['data_points'] == len(stable_demand_history)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_statistical_safety_stock_calculation():
|
||||
"""Test statistical safety stock calculation method."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
demand_stats = {
|
||||
'mean_demand': 100,
|
||||
'std_demand': 20,
|
||||
'mean_lead_time': 3,
|
||||
'std_lead_time': 0.5,
|
||||
'cv_demand': 0.2
|
||||
}
|
||||
|
||||
product_chars = {
|
||||
'criticality': 'high',
|
||||
'avg_daily_demand': 100
|
||||
}
|
||||
|
||||
result = optimizer._calculate_statistical_safety_stock(
|
||||
demand_stats, product_chars, supplier_reliability=0.95
|
||||
)
|
||||
|
||||
# Check structure
|
||||
assert 'method' in result
|
||||
assert result['method'] == 'statistical'
|
||||
assert 'safety_stock' in result
|
||||
assert 'service_level' in result
|
||||
assert 'z_score' in result
|
||||
|
||||
# High criticality should get 98% service level
|
||||
assert result['service_level'] == 0.98
|
||||
assert result['safety_stock'] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_criticality_affects_service_level():
|
||||
"""Test that product criticality affects target service level."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
demand_stats = {
|
||||
'mean_demand': 100,
|
||||
'std_demand': 20,
|
||||
'mean_lead_time': 3,
|
||||
'std_lead_time': 0.5
|
||||
}
|
||||
|
||||
# High criticality
|
||||
high_result = optimizer._calculate_statistical_safety_stock(
|
||||
demand_stats,
|
||||
{'criticality': 'high', 'avg_daily_demand': 100},
|
||||
None
|
||||
)
|
||||
|
||||
# Low criticality
|
||||
low_result = optimizer._calculate_statistical_safety_stock(
|
||||
demand_stats,
|
||||
{'criticality': 'low', 'avg_daily_demand': 100},
|
||||
None
|
||||
)
|
||||
|
||||
# High criticality should have higher service level and safety stock
|
||||
assert high_result['service_level'] > low_result['service_level']
|
||||
assert high_result['safety_stock'] > low_result['safety_stock']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_based_optimization(stable_demand_history, high_criticality_product, cost_parameters):
|
||||
"""Test cost-based safety stock optimization."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='test-product',
|
||||
demand_history=stable_demand_history,
|
||||
product_characteristics=high_criticality_product,
|
||||
cost_parameters=cost_parameters
|
||||
)
|
||||
|
||||
optimal = results['optimal_result']
|
||||
|
||||
# Should use cost optimization method for high-value product
|
||||
# (unit_cost > 5)
|
||||
assert optimal['method'] == 'cost_optimization'
|
||||
assert 'annual_total_cost' in optimal
|
||||
assert optimal['annual_total_cost'] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_comparison_with_hardcoded(stable_demand_history, high_criticality_product, cost_parameters):
|
||||
"""Test comparison between optimal and hardcoded approaches."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='test-product',
|
||||
demand_history=stable_demand_history,
|
||||
product_characteristics=high_criticality_product,
|
||||
cost_parameters=cost_parameters
|
||||
)
|
||||
|
||||
comparison = results['comparison']
|
||||
|
||||
# Check comparison metrics present
|
||||
assert 'stock_difference' in comparison
|
||||
assert 'stock_difference_pct' in comparison
|
||||
assert 'optimal_service_level' in comparison
|
||||
assert 'hardcoded_service_level' in comparison
|
||||
|
||||
# Should have cost savings calculation
|
||||
if cost_parameters:
|
||||
assert 'annual_holding_cost_savings' in comparison
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_over_stocking_insight_generation(stable_demand_history, low_criticality_product, cost_parameters):
|
||||
"""Test insight generation when product is over-stocked."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
# Low criticality product with stable demand should recommend reduction
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='overstocked-product',
|
||||
demand_history=stable_demand_history,
|
||||
product_characteristics=low_criticality_product,
|
||||
cost_parameters=cost_parameters
|
||||
)
|
||||
|
||||
insights = results['insights']
|
||||
|
||||
# Should generate insights
|
||||
assert len(insights) > 0
|
||||
|
||||
# Check for reduction insight (if optimal is significantly lower)
|
||||
comparison = results['comparison']
|
||||
if comparison['stock_difference_pct'] < -10:
|
||||
reduction_insights = [i for i in insights if 'reduce' in i.get('title', '').lower()]
|
||||
assert len(reduction_insights) > 0
|
||||
|
||||
insight = reduction_insights[0]
|
||||
assert insight['type'] == 'optimization'
|
||||
assert insight['impact_type'] == 'cost_savings'
|
||||
assert 'actionable' in insight
|
||||
assert insight['actionable'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_under_stocking_insight_generation(variable_demand_history, high_criticality_product):
|
||||
"""Test insight generation when product is under-stocked."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
# High criticality with variable demand should need more stock
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='understocked-product',
|
||||
demand_history=variable_demand_history,
|
||||
product_characteristics=high_criticality_product
|
||||
)
|
||||
|
||||
insights = results['insights']
|
||||
|
||||
# Check for increase recommendation
|
||||
comparison = results['comparison']
|
||||
if comparison['stock_difference_pct'] > 10:
|
||||
increase_insights = [i for i in insights if 'increase' in i.get('title', '').lower()]
|
||||
|
||||
if increase_insights:
|
||||
insight = increase_insights[0]
|
||||
assert insight['type'] in ['alert', 'recommendation']
|
||||
assert 'recommendation_actions' in insight
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_high_variability_insight(variable_demand_history, high_criticality_product):
|
||||
"""Test insight generation for high demand variability."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='variable-product',
|
||||
demand_history=variable_demand_history,
|
||||
product_characteristics=high_criticality_product
|
||||
)
|
||||
|
||||
insights = results['insights']
|
||||
stats = results['demand_stats']
|
||||
|
||||
# If CV > 0.5, should generate variability insight
|
||||
if stats['cv_demand'] > 0.5:
|
||||
variability_insights = [i for i in insights if 'variability' in i.get('title', '').lower()]
|
||||
assert len(variability_insights) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stockout_alert_generation():
|
||||
"""Test alert generation for frequent stockouts."""
|
||||
# Create demand history with frequent stockouts
|
||||
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')
|
||||
|
||||
data = {
|
||||
'date': dates,
|
||||
'demand_quantity': np.random.normal(100, 20, len(dates)),
|
||||
'lead_time_days': [3] * len(dates),
|
||||
'stockout': np.random.random(len(dates)) < 0.15 # 15% stockout rate
|
||||
}
|
||||
|
||||
demand_history = pd.DataFrame(data)
|
||||
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='stockout-product',
|
||||
demand_history=demand_history,
|
||||
product_characteristics={'criticality': 'high', 'avg_daily_demand': 100}
|
||||
)
|
||||
|
||||
insights = results['insights']
|
||||
|
||||
# Should generate stockout alert
|
||||
stockout_insights = [i for i in insights if 'stockout' in i.get('title', '').lower()]
|
||||
assert len(stockout_insights) > 0
|
||||
|
||||
insight = stockout_insights[0]
|
||||
assert insight['priority'] in ['high', 'critical']
|
||||
assert insight['type'] == 'alert'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shelf_life_constraint():
|
||||
"""Test that shelf life constrains safety stock."""
|
||||
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')
|
||||
|
||||
demand_history = pd.DataFrame({
|
||||
'date': dates,
|
||||
'demand_quantity': np.random.normal(100, 30, len(dates)), # High variance
|
||||
'lead_time_days': [3] * len(dates),
|
||||
'stockout': [False] * len(dates)
|
||||
})
|
||||
|
||||
# Product with short shelf life
|
||||
product_chars = {
|
||||
'shelf_life_days': 3, # Very short shelf life
|
||||
'criticality': 'high',
|
||||
'unit_cost': 5.0,
|
||||
'avg_daily_demand': 100
|
||||
}
|
||||
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='perishable-product',
|
||||
demand_history=demand_history,
|
||||
product_characteristics=product_chars
|
||||
)
|
||||
|
||||
optimal = results['optimal_result']
|
||||
|
||||
# Safety stock should be constrained by shelf life
|
||||
# Max allowed = avg_daily_demand * (shelf_life * 0.5)
|
||||
max_allowed = product_chars['avg_daily_demand'] * (product_chars['shelf_life_days'] * 0.5)
|
||||
|
||||
assert optimal['safety_stock'] <= max_allowed + 1 # Allow small rounding
|
||||
|
||||
# Should have constraint flag
|
||||
if optimal['safety_stock'] >= max_allowed - 1:
|
||||
assert optimal.get('constrained_by') == 'shelf_life'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplier_reliability_adjustment():
|
||||
"""Test that low supplier reliability increases safety stock."""
|
||||
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')
|
||||
|
||||
demand_history = pd.DataFrame({
|
||||
'date': dates,
|
||||
'demand_quantity': np.random.normal(100, 15, len(dates)),
|
||||
'lead_time_days': [3] * len(dates),
|
||||
'stockout': [False] * len(dates)
|
||||
})
|
||||
|
||||
product_chars = {
|
||||
'criticality': 'medium',
|
||||
'avg_daily_demand': 100
|
||||
}
|
||||
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
# Good supplier
|
||||
results_good = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='test-product',
|
||||
demand_history=demand_history,
|
||||
product_characteristics=product_chars,
|
||||
supplier_reliability=0.98
|
||||
)
|
||||
|
||||
# Poor supplier
|
||||
results_poor = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='test-product',
|
||||
demand_history=demand_history,
|
||||
product_characteristics=product_chars,
|
||||
supplier_reliability=0.85
|
||||
)
|
||||
|
||||
# Poor supplier should require higher safety stock
|
||||
assert results_poor['optimal_result']['safety_stock'] >= results_good['optimal_result']['safety_stock']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_data_handling():
|
||||
"""Test handling of insufficient demand history."""
|
||||
# Only 30 days (less than min_history_days=90)
|
||||
dates = pd.date_range(start='2024-01-01', end='2024-01-30', freq='D')
|
||||
|
||||
small_history = pd.DataFrame({
|
||||
'date': dates,
|
||||
'demand_quantity': np.random.normal(100, 15, len(dates)),
|
||||
'lead_time_days': [3] * len(dates),
|
||||
'stockout': [False] * len(dates)
|
||||
})
|
||||
|
||||
product_chars = {
|
||||
'criticality': 'high',
|
||||
'avg_daily_demand': 100,
|
||||
'shelf_life_days': 7
|
||||
}
|
||||
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='new-product',
|
||||
demand_history=small_history,
|
||||
product_characteristics=product_chars,
|
||||
min_history_days=90
|
||||
)
|
||||
|
||||
# Should return fallback response
|
||||
assert results['history_days'] == 0
|
||||
assert results['optimal_result']['method'] == 'fallback_heuristic'
|
||||
|
||||
# Should use simple heuristic (7 days for high criticality)
|
||||
expected_safety_stock = product_chars['avg_daily_demand'] * 7
|
||||
assert results['optimal_result']['safety_stock'] == expected_safety_stock
|
||||
|
||||
|
||||
def test_get_optimal_safety_stock():
|
||||
"""Test retrieval of cached optimal safety stock."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
# Initially no cached value
|
||||
assert optimizer.get_optimal_safety_stock('product-1') is None
|
||||
|
||||
# Set a value
|
||||
optimizer.optimal_stocks['product-1'] = 150.5
|
||||
|
||||
# Should retrieve it
|
||||
assert optimizer.get_optimal_safety_stock('product-1') == 150.5
|
||||
|
||||
|
||||
def test_get_learned_service_level():
|
||||
"""Test retrieval of learned service levels."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
# Initially no learned level
|
||||
assert optimizer.get_learned_service_level('product-1') is None
|
||||
|
||||
# Set a level
|
||||
optimizer.learned_service_levels['product-1'] = 0.96
|
||||
|
||||
# Should retrieve it
|
||||
assert optimizer.get_learned_service_level('product-1') == 0.96
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hardcoded_comparison():
|
||||
"""Test comparison specifically highlights hardcoded vs optimal."""
|
||||
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')
|
||||
|
||||
demand_history = pd.DataFrame({
|
||||
'date': dates,
|
||||
'demand_quantity': np.random.normal(100, 15, len(dates)),
|
||||
'lead_time_days': [3] * len(dates),
|
||||
'stockout': [False] * len(dates)
|
||||
})
|
||||
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
results = await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id='test-product',
|
||||
demand_history=demand_history,
|
||||
product_characteristics={'criticality': 'medium', 'avg_daily_demand': 100}
|
||||
)
|
||||
|
||||
# Hardcoded should always be 95% service level
|
||||
assert results['hardcoded_result']['service_level'] == 0.95
|
||||
assert results['hardcoded_result']['method'] == 'hardcoded_95_service_level'
|
||||
|
||||
# Comparison should show difference
|
||||
comparison = results['comparison']
|
||||
assert 'stock_difference' in comparison
|
||||
assert 'service_level_difference' in comparison
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_products_caching():
|
||||
"""Test that optimizer caches results for multiple products."""
|
||||
optimizer = SafetyStockOptimizer()
|
||||
|
||||
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')
|
||||
|
||||
# Optimize multiple products
|
||||
for i in range(3):
|
||||
demand_history = pd.DataFrame({
|
||||
'date': dates,
|
||||
'demand_quantity': np.random.normal(100 + i*10, 15, len(dates)),
|
||||
'lead_time_days': [3] * len(dates),
|
||||
'stockout': [False] * len(dates)
|
||||
})
|
||||
|
||||
await optimizer.optimize_safety_stock(
|
||||
tenant_id='test-tenant',
|
||||
inventory_product_id=f'product-{i}',
|
||||
demand_history=demand_history,
|
||||
product_characteristics={'criticality': 'medium', 'avg_daily_demand': 100 + i*10}
|
||||
)
|
||||
|
||||
# Should have cached all three
|
||||
assert len(optimizer.optimal_stocks) == 3
|
||||
assert len(optimizer.learned_service_levels) == 3
|
||||
|
||||
# Each should be retrievable
|
||||
for i in range(3):
|
||||
assert optimizer.get_optimal_safety_stock(f'product-{i}') is not None
|
||||
assert optimizer.get_learned_service_level(f'product-{i}') is not None
|
||||
Reference in New Issue
Block a user