Files
bakery-ia/services/procurement/app/ml/price_forecaster.py

852 lines
31 KiB
Python
Raw Normal View History

2025-11-05 13:34:56 +01:00
"""
Price Forecaster
Predicts supplier price changes for opportunistic buying recommendations
Identifies optimal timing for bulk purchases and price negotiation opportunities
"""
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 sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
import warnings
warnings.filterwarnings('ignore')
logger = structlog.get_logger()
class PriceForecaster:
"""
Forecasts ingredient and product prices for opportunistic procurement.
Capabilities:
1. Short-term price forecasting (1-4 weeks)
2. Seasonal price pattern detection
3. Price trend analysis
4. Buy/wait recommendations
5. Bulk purchase opportunity identification
6. Price volatility assessment
7. Supplier comparison for price optimization
"""
def __init__(self):
self.price_models = {}
self.seasonal_patterns = {}
self.volatility_scores = {}
async def forecast_price(
self,
tenant_id: str,
ingredient_id: str,
price_history: pd.DataFrame,
forecast_horizon_days: int = 30,
min_history_days: int = 180
) -> Dict[str, Any]:
"""
Forecast future prices and generate procurement recommendations.
Args:
tenant_id: Tenant identifier
ingredient_id: Ingredient/product identifier
price_history: Historical price data with columns:
- date
- price_per_unit
- quantity_purchased (optional)
- supplier_id (optional)
forecast_horizon_days: Days to forecast ahead (default 30)
min_history_days: Minimum days of history required (default 180)
Returns:
Dictionary with price forecast and insights
"""
logger.info(
"Forecasting prices",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
history_days=len(price_history),
forecast_days=forecast_horizon_days
)
# Validate input
if len(price_history) < min_history_days:
logger.warning(
"Insufficient price history",
ingredient_id=ingredient_id,
days=len(price_history),
required=min_history_days
)
return self._insufficient_data_response(
tenant_id, ingredient_id, price_history
)
# Prepare data
price_history = price_history.copy()
price_history['date'] = pd.to_datetime(price_history['date'])
price_history = price_history.sort_values('date')
# Calculate price statistics
price_stats = self._calculate_price_statistics(price_history)
# Detect seasonal patterns
seasonal_analysis = self._detect_seasonal_patterns(price_history)
# Detect trends
trend_analysis = self._analyze_price_trends(price_history)
# Forecast future prices
forecast = self._generate_price_forecast(
price_history,
forecast_horizon_days,
seasonal_analysis,
trend_analysis
)
# Calculate volatility
volatility = self._calculate_price_volatility(price_history)
# Generate buy/wait recommendations
recommendations = self._generate_procurement_recommendations(
price_history,
forecast,
price_stats,
volatility,
trend_analysis
)
# Identify bulk purchase opportunities
bulk_opportunities = self._identify_bulk_opportunities(
forecast,
price_stats,
volatility
)
# Generate insights
insights = self._generate_price_insights(
tenant_id,
ingredient_id,
price_stats,
forecast,
recommendations,
bulk_opportunities,
trend_analysis,
volatility
)
# Store models
self.seasonal_patterns[ingredient_id] = seasonal_analysis
self.volatility_scores[ingredient_id] = volatility
logger.info(
"Price forecasting complete",
ingredient_id=ingredient_id,
avg_forecast_price=forecast['mean_forecast_price'],
recommendation=recommendations['action'],
insights_generated=len(insights)
)
return {
'tenant_id': tenant_id,
'ingredient_id': ingredient_id,
'forecasted_at': datetime.utcnow().isoformat(),
'history_days': len(price_history),
'forecast_horizon_days': forecast_horizon_days,
'price_stats': price_stats,
'seasonal_analysis': seasonal_analysis,
'trend_analysis': trend_analysis,
'forecast': forecast,
'volatility': volatility,
'recommendations': recommendations,
'bulk_opportunities': bulk_opportunities,
'insights': insights
}
def _calculate_price_statistics(
self,
price_history: pd.DataFrame
) -> Dict[str, float]:
"""
Calculate comprehensive price statistics.
Args:
price_history: Historical price data
Returns:
Dictionary of price statistics
"""
prices = price_history['price_per_unit'].values
# Basic statistics
current_price = float(prices[-1])
mean_price = float(prices.mean())
std_price = float(prices.std())
cv_price = (std_price / mean_price) if mean_price > 0 else 0
# Price range
min_price = float(prices.min())
max_price = float(prices.max())
price_range_pct = ((max_price - min_price) / mean_price * 100) if mean_price > 0 else 0
# Recent vs historical
if len(prices) >= 60:
recent_30d_mean = float(prices[-30:].mean())
historical_mean = float(prices[:-30].mean())
price_change_pct = ((recent_30d_mean - historical_mean) / historical_mean * 100) if historical_mean > 0 else 0
else:
recent_30d_mean = current_price
price_change_pct = 0
# Price momentum (last 7 days vs previous 7 days)
if len(prices) >= 14:
last_week = prices[-7:].mean()
prev_week = prices[-14:-7].mean()
momentum = ((last_week - prev_week) / prev_week * 100) if prev_week > 0 else 0
else:
momentum = 0
return {
'current_price': current_price,
'mean_price': mean_price,
'std_price': std_price,
'cv_price': cv_price,
'min_price': min_price,
'max_price': max_price,
'price_range_pct': price_range_pct,
'recent_30d_mean': recent_30d_mean,
'price_change_30d_pct': price_change_pct,
'momentum_7d_pct': momentum,
'data_points': len(prices)
}
def _detect_seasonal_patterns(
self,
price_history: pd.DataFrame
) -> Dict[str, Any]:
"""
Detect seasonal price patterns.
Args:
price_history: Historical price data
Returns:
Seasonal pattern analysis
"""
# Extract month from date
price_history = price_history.copy()
price_history['month'] = price_history['date'].dt.month
# Calculate average price per month
monthly_avg = price_history.groupby('month')['price_per_unit'].agg(['mean', 'std', 'count'])
overall_mean = price_history['price_per_unit'].mean()
seasonal_patterns = {}
has_seasonality = False
month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
for month in range(1, 13):
if month in monthly_avg.index and monthly_avg.loc[month, 'count'] >= 3:
month_mean = monthly_avg.loc[month, 'mean']
deviation_pct = ((month_mean - overall_mean) / overall_mean * 100) if overall_mean > 0 else 0
seasonal_patterns[month_names[month-1]] = {
'month': month,
'avg_price': round(float(month_mean), 2),
'deviation_pct': round(float(deviation_pct), 2),
'sample_size': int(monthly_avg.loc[month, 'count'])
}
# Significant seasonality if >10% deviation
if abs(deviation_pct) > 10:
has_seasonality = True
return {
'has_seasonality': has_seasonality,
'monthly_patterns': seasonal_patterns,
'overall_mean_price': round(float(overall_mean), 2)
}
def _analyze_price_trends(
self,
price_history: pd.DataFrame
) -> Dict[str, Any]:
"""
Analyze price trends using linear regression.
Args:
price_history: Historical price data
Returns:
Trend analysis
"""
# Create time index (days from start)
price_history = price_history.copy()
price_history['days_from_start'] = (
price_history['date'] - price_history['date'].min()
).dt.days
X = price_history['days_from_start'].values.reshape(-1, 1)
y = price_history['price_per_unit'].values
# Fit linear regression
model = LinearRegression()
model.fit(X, y)
# Calculate trend
slope = float(model.coef_[0])
intercept = float(model.intercept_)
r_squared = float(model.score(X, y))
# Trend direction and magnitude
avg_price = y.mean()
trend_pct_per_month = (slope * 30 / avg_price * 100) if avg_price > 0 else 0
# Classify trend
if abs(trend_pct_per_month) < 2:
trend_direction = 'stable'
elif trend_pct_per_month > 2:
trend_direction = 'increasing'
else:
trend_direction = 'decreasing'
# Recent trend (last 90 days)
if len(price_history) >= 90:
recent_data = price_history.tail(90).copy()
recent_X = recent_data['days_from_start'].values.reshape(-1, 1)
recent_y = recent_data['price_per_unit'].values
recent_model = LinearRegression()
recent_model.fit(recent_X, recent_y)
recent_slope = float(recent_model.coef_[0])
recent_trend_pct = (recent_slope * 30 / recent_y.mean() * 100) if recent_y.mean() > 0 else 0
else:
recent_trend_pct = trend_pct_per_month
return {
'trend_direction': trend_direction,
'trend_pct_per_month': round(trend_pct_per_month, 2),
'recent_trend_pct_per_month': round(recent_trend_pct, 2),
'slope': round(slope, 4),
'r_squared': round(r_squared, 3),
'is_accelerating': abs(recent_trend_pct) > abs(trend_pct_per_month) * 1.5
}
def _generate_price_forecast(
self,
price_history: pd.DataFrame,
forecast_days: int,
seasonal_analysis: Dict[str, Any],
trend_analysis: Dict[str, Any]
) -> Dict[str, Any]:
"""
Generate price forecast for specified horizon.
Args:
price_history: Historical price data
forecast_days: Days to forecast
seasonal_analysis: Seasonal patterns
trend_analysis: Trend analysis
Returns:
Price forecast
"""
current_price = price_history['price_per_unit'].iloc[-1]
current_date = price_history['date'].iloc[-1]
# Simple forecast: current price + trend + seasonal adjustment
trend_slope = trend_analysis['slope']
forecast_prices = []
forecast_dates = []
for day in range(1, forecast_days + 1):
forecast_date = current_date + timedelta(days=day)
forecast_dates.append(forecast_date)
# Base forecast from trend
base_forecast = current_price + (trend_slope * day)
# Seasonal adjustment
if seasonal_analysis['has_seasonality']:
month_name = forecast_date.strftime('%b')
if month_name in seasonal_analysis['monthly_patterns']:
month_deviation = seasonal_analysis['monthly_patterns'][month_name]['deviation_pct']
seasonal_adjustment = base_forecast * (month_deviation / 100)
base_forecast += seasonal_adjustment
forecast_prices.append(base_forecast)
forecast_prices = np.array(forecast_prices)
# Calculate confidence intervals (±2 std)
historical_std = price_history['price_per_unit'].std()
lower_bound = forecast_prices - 2 * historical_std
upper_bound = forecast_prices + 2 * historical_std
return {
'forecast_dates': [d.strftime('%Y-%m-%d') for d in forecast_dates],
'forecast_prices': [round(float(p), 2) for p in forecast_prices],
'lower_bound': [round(float(p), 2) for p in lower_bound],
'upper_bound': [round(float(p), 2) for p in upper_bound],
'mean_forecast_price': round(float(forecast_prices.mean()), 2),
'min_forecast_price': round(float(forecast_prices.min()), 2),
'max_forecast_price': round(float(forecast_prices.max()), 2),
'confidence': self._calculate_forecast_confidence(price_history, trend_analysis)
}
def _calculate_forecast_confidence(
self,
price_history: pd.DataFrame,
trend_analysis: Dict[str, Any]
) -> int:
"""Calculate confidence in price forecast (0-100)."""
confidence = 50 # Base confidence
# More data = higher confidence
data_points = len(price_history)
if data_points >= 365:
confidence += 30
elif data_points >= 180:
confidence += 20
else:
confidence += 10
# Strong trend = higher confidence
r_squared = trend_analysis['r_squared']
if r_squared > 0.7:
confidence += 20
elif r_squared > 0.5:
confidence += 10
# Low volatility = higher confidence
cv = price_history['price_per_unit'].std() / price_history['price_per_unit'].mean()
if cv < 0.1:
confidence += 10
elif cv < 0.2:
confidence += 5
return min(100, confidence)
def _calculate_price_volatility(
self,
price_history: pd.DataFrame
) -> Dict[str, Any]:
"""
Calculate price volatility metrics.
Args:
price_history: Historical price data
Returns:
Volatility analysis
"""
prices = price_history['price_per_unit'].values
# Coefficient of variation
cv = float(prices.std() / prices.mean()) if prices.mean() > 0 else 0
# Price changes (day-to-day)
price_changes = np.diff(prices)
pct_changes = (price_changes / prices[:-1] * 100)
# Volatility classification
if cv < 0.1:
volatility_level = 'low'
elif cv < 0.2:
volatility_level = 'medium'
else:
volatility_level = 'high'
return {
'coefficient_of_variation': round(cv, 3),
'volatility_level': volatility_level,
'avg_daily_change_pct': round(float(np.abs(pct_changes).mean()), 2),
'max_daily_increase_pct': round(float(pct_changes.max()), 2),
'max_daily_decrease_pct': round(float(pct_changes.min()), 2)
}
def _generate_procurement_recommendations(
self,
price_history: pd.DataFrame,
forecast: Dict[str, Any],
price_stats: Dict[str, float],
volatility: Dict[str, Any],
trend_analysis: Dict[str, Any]
) -> Dict[str, Any]:
"""
Generate buy/wait recommendations based on forecast.
Args:
price_history: Historical data
forecast: Price forecast
price_stats: Price statistics
volatility: Volatility analysis
trend_analysis: Trend analysis
Returns:
Procurement recommendations
"""
current_price = price_stats['current_price']
forecast_mean = forecast['mean_forecast_price']
forecast_min = forecast['min_forecast_price']
# Calculate expected price change
expected_change_pct = ((forecast_mean - current_price) / current_price * 100) if current_price > 0 else 0
fix: Replace all remaining hardcoded English reasoning with structured codes This commit removes the last hardcoded English text from reasoning fields across all backend services, completing the i18n implementation. Changes by service: Safety Stock Calculator (safety_stock_calculator.py): - CALC:STATISTICAL_Z_SCORE - Statistical calculation with Z-score - CALC:ADVANCED_VARIABILITY - Advanced formula with demand and lead time variability - CALC:FIXED_PERCENTAGE - Fixed percentage of lead time demand - All calculation methods now use structured codes with pipe-separated parameters Price Forecaster (price_forecaster.py): - PRICE_FORECAST:DECREASE_EXPECTED - Price expected to decrease - PRICE_FORECAST:INCREASE_EXPECTED - Price expected to increase - PRICE_FORECAST:HIGH_VOLATILITY - High price volatility detected - PRICE_FORECAST:BELOW_AVERAGE - Current price below average (buy opportunity) - PRICE_FORECAST:STABLE - Price stable, normal schedule - All forecasts include relevant parameters (change_pct, days, etc.) Optimization Utils (shared/utils/optimization.py): - EOQ:BASE - Economic Order Quantity base calculation - EOQ:MOQ_APPLIED - Minimum order quantity constraint applied - EOQ:MAX_APPLIED - Maximum order quantity constraint applied - TIER_PRICING:CURRENT_TIER - Current tier pricing - TIER_PRICING:UPGRADED - Upgraded to higher tier for savings - All optimizations include calculation parameters Format: All codes use pattern "CATEGORY:TYPE|param1=value|param2=value" This allows frontend to parse and translate with parameters while maintaining technical accuracy for logging and debugging. Frontend can now translate ALL reasoning codes across the entire system.
2025-11-07 19:00:00 +00:00
# Decision logic with i18n-friendly reasoning codes
2025-11-05 13:34:56 +01:00
if expected_change_pct < -5:
# Price expected to drop >5%
action = 'wait'
reasoning_data = {
'type': 'decrease_expected',
'parameters': {
'change_pct': round(abs(expected_change_pct), 1),
'forecast_days': 30,
'current_price': round(current_price, 2),
'forecast_mean': round(forecast_mean, 2)
}
}
2025-11-05 13:34:56 +01:00
urgency = 'low'
elif expected_change_pct > 5:
# Price expected to increase >5%
action = 'buy_now'
reasoning_data = {
'type': 'increase_expected',
'parameters': {
'change_pct': round(expected_change_pct, 1),
'forecast_days': 30,
'current_price': round(current_price, 2),
'forecast_mean': round(forecast_mean, 2)
}
}
2025-11-05 13:34:56 +01:00
urgency = 'high'
elif volatility['volatility_level'] == 'high':
# High volatility - wait for dip
action = 'wait_for_dip'
reasoning_data = {
'type': 'high_volatility',
'parameters': {
'coefficient': round(volatility['coefficient_of_variation'], 2),
'volatility_level': volatility['volatility_level'],
'avg_daily_change_pct': round(volatility['avg_daily_change_pct'], 2)
}
}
2025-11-05 13:34:56 +01:00
urgency = 'medium'
elif current_price < price_stats['mean_price'] * 0.95:
# Currently below average
fix: Replace all remaining hardcoded English reasoning with structured codes This commit removes the last hardcoded English text from reasoning fields across all backend services, completing the i18n implementation. Changes by service: Safety Stock Calculator (safety_stock_calculator.py): - CALC:STATISTICAL_Z_SCORE - Statistical calculation with Z-score - CALC:ADVANCED_VARIABILITY - Advanced formula with demand and lead time variability - CALC:FIXED_PERCENTAGE - Fixed percentage of lead time demand - All calculation methods now use structured codes with pipe-separated parameters Price Forecaster (price_forecaster.py): - PRICE_FORECAST:DECREASE_EXPECTED - Price expected to decrease - PRICE_FORECAST:INCREASE_EXPECTED - Price expected to increase - PRICE_FORECAST:HIGH_VOLATILITY - High price volatility detected - PRICE_FORECAST:BELOW_AVERAGE - Current price below average (buy opportunity) - PRICE_FORECAST:STABLE - Price stable, normal schedule - All forecasts include relevant parameters (change_pct, days, etc.) Optimization Utils (shared/utils/optimization.py): - EOQ:BASE - Economic Order Quantity base calculation - EOQ:MOQ_APPLIED - Minimum order quantity constraint applied - EOQ:MAX_APPLIED - Maximum order quantity constraint applied - TIER_PRICING:CURRENT_TIER - Current tier pricing - TIER_PRICING:UPGRADED - Upgraded to higher tier for savings - All optimizations include calculation parameters Format: All codes use pattern "CATEGORY:TYPE|param1=value|param2=value" This allows frontend to parse and translate with parameters while maintaining technical accuracy for logging and debugging. Frontend can now translate ALL reasoning codes across the entire system.
2025-11-07 19:00:00 +00:00
below_avg_pct = ((price_stats["mean_price"] - current_price) / price_stats["mean_price"] * 100)
2025-11-05 13:34:56 +01:00
action = 'buy_now'
reasoning_data = {
'type': 'below_average',
'parameters': {
'current_price': round(current_price, 2),
'mean_price': round(price_stats['mean_price'], 2),
'below_avg_pct': round(below_avg_pct, 1)
}
}
2025-11-05 13:34:56 +01:00
urgency = 'medium'
else:
# Neutral
action = 'normal_purchase'
reasoning_data = {
'type': 'stable',
'parameters': {
'current_price': round(current_price, 2),
'forecast_mean': round(forecast_mean, 2),
'expected_change_pct': round(expected_change_pct, 2)
}
}
2025-11-05 13:34:56 +01:00
urgency = 'low'
# Optimal purchase timing
min_price_index = forecast['forecast_prices'].index(forecast_min)
optimal_date = forecast['forecast_dates'][min_price_index]
return {
'action': action,
'reasoning_data': reasoning_data,
2025-11-05 13:34:56 +01:00
'urgency': urgency,
'expected_price_change_pct': round(expected_change_pct, 2),
'current_price': current_price,
'forecast_mean_price': forecast_mean,
'forecast_min_price': forecast_min,
'optimal_purchase_date': optimal_date,
'days_until_optimal': min_price_index + 1
}
def _identify_bulk_opportunities(
self,
forecast: Dict[str, Any],
price_stats: Dict[str, float],
volatility: Dict[str, Any]
) -> Dict[str, Any]:
"""
Identify bulk purchase opportunities.
Args:
forecast: Price forecast
price_stats: Price statistics
volatility: Volatility analysis
Returns:
Bulk opportunity analysis
"""
current_price = price_stats['current_price']
forecast_max = forecast['max_forecast_price']
# Potential savings from bulk buy at current price
if forecast_max > current_price:
potential_savings_pct = ((forecast_max - current_price) / current_price * 100)
if potential_savings_pct > 10:
opportunity_level = 'high'
elif potential_savings_pct > 5:
opportunity_level = 'medium'
else:
opportunity_level = 'low'
has_opportunity = potential_savings_pct > 5
else:
potential_savings_pct = 0
opportunity_level = 'none'
has_opportunity = False
return {
'has_bulk_opportunity': has_opportunity,
'opportunity_level': opportunity_level,
'potential_savings_pct': round(potential_savings_pct, 2),
'recommended_bulk_quantity_months': 2 if has_opportunity and volatility['volatility_level'] != 'high' else 1
}
def _generate_price_insights(
self,
tenant_id: str,
ingredient_id: str,
price_stats: Dict[str, float],
forecast: Dict[str, Any],
recommendations: Dict[str, Any],
bulk_opportunities: Dict[str, Any],
trend_analysis: Dict[str, Any],
volatility: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""
Generate actionable pricing insights.
Returns:
List of insights
"""
insights = []
# Insight 1: Buy now recommendation
if recommendations['action'] == 'buy_now':
insights.append({
'type': 'recommendation',
'priority': recommendations['urgency'],
'category': 'procurement',
'title': f'Buy Now: Price Increasing {recommendations["expected_price_change_pct"]:.1f}%',
'reasoning_data': recommendations['reasoning_data'],
2025-11-05 13:34:56 +01:00
'impact_type': 'cost_avoidance',
'impact_value': abs(recommendations['expected_price_change_pct']),
'impact_unit': 'percentage',
'confidence': forecast['confidence'],
'metrics_json': {
'ingredient_id': ingredient_id,
'current_price': price_stats['current_price'],
'forecast_price': forecast['mean_forecast_price'],
'expected_change_pct': recommendations['expected_price_change_pct'],
'optimal_date': recommendations['optimal_purchase_date']
},
'actionable': True,
'recommendation_actions': [
{
'label': 'Purchase Now',
'action': 'create_purchase_order',
'params': {
'ingredient_id': ingredient_id,
'priority': 'high'
}
}
],
'source_service': 'procurement',
'source_model': 'price_forecaster'
})
# Insight 2: Wait recommendation
elif recommendations['action'] == 'wait':
insights.append({
'type': 'recommendation',
'priority': 'medium',
'category': 'procurement',
'title': f'Wait to Buy: Price Decreasing {abs(recommendations["expected_price_change_pct"]):.1f}%',
'reasoning_data': {
**recommendations['reasoning_data'],
'optimal_purchase_date': recommendations['optimal_purchase_date'],
'days_until_optimal': recommendations['days_until_optimal']
},
2025-11-05 13:34:56 +01:00
'impact_type': 'cost_savings',
'impact_value': abs(recommendations['expected_price_change_pct']),
'impact_unit': 'percentage',
'confidence': forecast['confidence'],
'metrics_json': {
'ingredient_id': ingredient_id,
'current_price': price_stats['current_price'],
'forecast_min_price': forecast['min_forecast_price'],
'optimal_date': recommendations['optimal_purchase_date'],
'days_until_optimal': recommendations['days_until_optimal']
},
'actionable': True,
'recommendation_actions': [
{
'label': 'Delay Purchase',
'action': 'delay_purchase_order',
'params': {
'ingredient_id': ingredient_id,
'delay_days': recommendations['days_until_optimal']
}
}
],
'source_service': 'procurement',
'source_model': 'price_forecaster'
})
# Insight 3: Bulk opportunity
if bulk_opportunities['has_bulk_opportunity']:
insights.append({
'type': 'optimization',
'priority': bulk_opportunities['opportunity_level'],
'category': 'procurement',
'title': f'Bulk Buy Opportunity: Save {bulk_opportunities["potential_savings_pct"]:.1f}%',
'description': f'Current price is favorable. Purchasing {bulk_opportunities["recommended_bulk_quantity_months"]} months supply now could save {bulk_opportunities["potential_savings_pct"]:.1f}% vs future prices.',
'impact_type': 'cost_savings',
'impact_value': bulk_opportunities['potential_savings_pct'],
'impact_unit': 'percentage',
'confidence': forecast['confidence'],
'metrics_json': {
'ingredient_id': ingredient_id,
'current_price': price_stats['current_price'],
'forecast_max_price': forecast['max_forecast_price'],
'savings_pct': bulk_opportunities['potential_savings_pct'],
'recommended_months_supply': bulk_opportunities['recommended_bulk_quantity_months']
},
'actionable': True,
'recommendation_actions': [
{
'label': 'Create Bulk Order',
'action': 'create_bulk_purchase_order',
'params': {
'ingredient_id': ingredient_id,
'months_supply': bulk_opportunities['recommended_bulk_quantity_months']
}
}
],
'source_service': 'procurement',
'source_model': 'price_forecaster'
})
# Insight 4: High volatility warning
if volatility['volatility_level'] == 'high':
insights.append({
'type': 'alert',
'priority': 'medium',
'category': 'procurement',
'title': f'High Price Volatility: CV={volatility["coefficient_of_variation"]:.2f}',
'description': f'Ingredient {ingredient_id} shows high price volatility with {volatility["avg_daily_change_pct"]:.1f}% average daily change. Consider alternative suppliers or hedge strategies.',
'impact_type': 'risk_warning',
'impact_value': volatility['coefficient_of_variation'],
'impact_unit': 'cv_score',
'confidence': 90,
'metrics_json': {
'ingredient_id': ingredient_id,
'volatility_level': volatility['volatility_level'],
'cv': volatility['coefficient_of_variation'],
'avg_daily_change_pct': volatility['avg_daily_change_pct']
},
'actionable': True,
'recommendation_actions': [
{
'label': 'Find Alternative Suppliers',
'action': 'search_alternative_suppliers',
'params': {'ingredient_id': ingredient_id}
}
],
'source_service': 'procurement',
'source_model': 'price_forecaster'
})
# Insight 5: Strong price trend
if abs(trend_analysis['trend_pct_per_month']) > 5:
direction = 'increasing' if trend_analysis['trend_pct_per_month'] > 0 else 'decreasing'
insights.append({
'type': 'insight',
'priority': 'medium',
'category': 'procurement',
'title': f'Strong Price Trend: {direction.title()} {abs(trend_analysis["trend_pct_per_month"]):.1f}%/month',
'description': f'Ingredient {ingredient_id} prices are {direction} at {abs(trend_analysis["trend_pct_per_month"]):.1f}% per month. Plan procurement strategy accordingly.',
'impact_type': 'trend_warning',
'impact_value': abs(trend_analysis['trend_pct_per_month']),
'impact_unit': 'pct_per_month',
'confidence': int(trend_analysis['r_squared'] * 100),
'metrics_json': {
'ingredient_id': ingredient_id,
'trend_direction': trend_analysis['trend_direction'],
'trend_pct_per_month': trend_analysis['trend_pct_per_month'],
'r_squared': trend_analysis['r_squared']
},
'actionable': False,
'source_service': 'procurement',
'source_model': 'price_forecaster'
})
return insights
def _insufficient_data_response(
self,
tenant_id: str,
ingredient_id: str,
price_history: pd.DataFrame
) -> Dict[str, Any]:
"""Return response when insufficient data available."""
return {
'tenant_id': tenant_id,
'ingredient_id': ingredient_id,
'forecasted_at': datetime.utcnow().isoformat(),
'history_days': len(price_history),
'forecast_horizon_days': 0,
'price_stats': {},
'seasonal_analysis': {'has_seasonality': False},
'trend_analysis': {},
'forecast': {},
'volatility': {},
'recommendations': {
'action': 'insufficient_data',
'reasoning_data': {
'type': 'insufficient_data',
'parameters': {
'history_days': len(price_history),
'min_required_days': 180
}
},
2025-11-05 13:34:56 +01:00
'urgency': 'low'
},
'bulk_opportunities': {'has_bulk_opportunity': False},
'insights': []
}
def get_seasonal_patterns(self, ingredient_id: str) -> Optional[Dict[str, Any]]:
"""Get cached seasonal patterns for an ingredient."""
return self.seasonal_patterns.get(ingredient_id)
def get_volatility_score(self, ingredient_id: str) -> Optional[Dict[str, Any]]:
"""Get cached volatility score for an ingredient."""
return self.volatility_scores.get(ingredient_id)