""" Inventory Projector Projects future inventory levels day-by-day to identify coverage gaps and stockout risks before they occur. """ from datetime import date, timedelta from decimal import Decimal from typing import List, Dict, Optional, Tuple from dataclasses import dataclass, field import logging from shared.utils.time_series_utils import generate_future_dates logger = logging.getLogger(__name__) @dataclass class DailyDemand: """Daily demand forecast for an ingredient""" ingredient_id: str date: date quantity: Decimal @dataclass class ScheduledReceipt: """Planned receipt (PO, production, etc.)""" ingredient_id: str date: date quantity: Decimal source: str # 'purchase_order', 'production', 'transfer' reference_id: Optional[str] = None @dataclass class InventoryLevel: """Current inventory level""" ingredient_id: str quantity: Decimal unit_of_measure: str @dataclass class DailyProjection: """Daily inventory projection""" date: date starting_stock: Decimal forecasted_consumption: Decimal scheduled_receipts: Decimal projected_ending_stock: Decimal is_stockout: bool coverage_gap: Decimal # Negative amount if stockout @dataclass class IngredientProjection: """Complete projection for one ingredient""" ingredient_id: str ingredient_name: str current_stock: Decimal unit_of_measure: str projection_horizon_days: int daily_projections: List[DailyProjection] = field(default_factory=list) total_consumption: Decimal = Decimal('0') total_receipts: Decimal = Decimal('0') stockout_days: int = 0 stockout_risk: str = "low" # low, medium, high class InventoryProjector: """ Projects inventory levels over time to identify coverage gaps. Algorithm: For each day in horizon: Starting Stock = Previous Day's Ending Stock Consumption = Forecasted Demand Receipts = Scheduled Deliveries + Production Ending Stock = Starting Stock - Consumption + Receipts Identifies: - Days when stock goes negative (stockouts) - Coverage gaps (how much short) - Stockout risk level """ def __init__(self, projection_horizon_days: int = 7): """ Initialize inventory projector. Args: projection_horizon_days: Number of days to project """ self.projection_horizon_days = projection_horizon_days def project_inventory( self, ingredient_id: str, ingredient_name: str, current_stock: Decimal, unit_of_measure: str, daily_demand: List[DailyDemand], scheduled_receipts: List[ScheduledReceipt], start_date: Optional[date] = None ) -> IngredientProjection: """ Project inventory levels for one ingredient. Args: ingredient_id: Ingredient ID ingredient_name: Ingredient name current_stock: Current inventory level unit_of_measure: Unit of measure daily_demand: List of daily demand forecasts scheduled_receipts: List of scheduled receipts start_date: Starting date (defaults to today) Returns: IngredientProjection with daily projections """ if start_date is None: start_date = date.today() # Generate projection dates projection_dates = generate_future_dates(start_date, self.projection_horizon_days) # Build demand lookup demand_by_date = {d.date: d.quantity for d in daily_demand} # Build receipts lookup receipts_by_date: Dict[date, Decimal] = {} for receipt in scheduled_receipts: if receipt.date not in receipts_by_date: receipts_by_date[receipt.date] = Decimal('0') receipts_by_date[receipt.date] += receipt.quantity # Project day by day daily_projections = [] running_stock = current_stock total_consumption = Decimal('0') total_receipts = Decimal('0') stockout_days = 0 for projection_date in projection_dates: starting_stock = running_stock # Get consumption for this day consumption = demand_by_date.get(projection_date, Decimal('0')) # Get receipts for this day receipts = receipts_by_date.get(projection_date, Decimal('0')) # Calculate ending stock ending_stock = starting_stock - consumption + receipts # Check for stockout is_stockout = ending_stock < Decimal('0') coverage_gap = min(Decimal('0'), ending_stock) if is_stockout: stockout_days += 1 # Create daily projection daily_proj = DailyProjection( date=projection_date, starting_stock=starting_stock, forecasted_consumption=consumption, scheduled_receipts=receipts, projected_ending_stock=ending_stock, is_stockout=is_stockout, coverage_gap=coverage_gap ) daily_projections.append(daily_proj) # Update running totals total_consumption += consumption total_receipts += receipts running_stock = ending_stock # Calculate stockout risk stockout_risk = self._calculate_stockout_risk( stockout_days=stockout_days, total_days=len(projection_dates), final_stock=running_stock ) return IngredientProjection( ingredient_id=ingredient_id, ingredient_name=ingredient_name, current_stock=current_stock, unit_of_measure=unit_of_measure, projection_horizon_days=self.projection_horizon_days, daily_projections=daily_projections, total_consumption=total_consumption, total_receipts=total_receipts, stockout_days=stockout_days, stockout_risk=stockout_risk ) def project_multiple_ingredients( self, ingredients_data: List[Dict] ) -> List[IngredientProjection]: """ Project inventory for multiple ingredients. Args: ingredients_data: List of dicts with ingredient data Returns: List of ingredient projections """ projections = [] for data in ingredients_data: projection = self.project_inventory( ingredient_id=data['ingredient_id'], ingredient_name=data['ingredient_name'], current_stock=data['current_stock'], unit_of_measure=data['unit_of_measure'], daily_demand=data.get('daily_demand', []), scheduled_receipts=data.get('scheduled_receipts', []), start_date=data.get('start_date') ) projections.append(projection) return projections def identify_coverage_gaps( self, projection: IngredientProjection ) -> List[Dict]: """ Identify all coverage gaps in projection. Args: projection: Ingredient projection Returns: List of coverage gap details """ gaps = [] for daily_proj in projection.daily_projections: if daily_proj.is_stockout: gap = { 'date': daily_proj.date, 'shortage_quantity': abs(daily_proj.coverage_gap), 'starting_stock': daily_proj.starting_stock, 'consumption': daily_proj.forecasted_consumption, 'receipts': daily_proj.scheduled_receipts } gaps.append(gap) if gaps: logger.warning( f"{projection.ingredient_name}: {len(gaps)} stockout days detected" ) return gaps def calculate_required_order_quantity( self, projection: IngredientProjection, target_coverage_days: int = 7 ) -> Decimal: """ Calculate how much to order to achieve target coverage. Args: projection: Ingredient projection target_coverage_days: Target days of coverage Returns: Required order quantity """ # Calculate average daily consumption if projection.daily_projections: avg_daily_consumption = projection.total_consumption / len(projection.daily_projections) else: return Decimal('0') # Target stock level target_stock = avg_daily_consumption * Decimal(str(target_coverage_days)) # Calculate shortfall final_projected_stock = projection.daily_projections[-1].projected_ending_stock if projection.daily_projections else Decimal('0') required_order = max(Decimal('0'), target_stock - final_projected_stock) return required_order def _calculate_stockout_risk( self, stockout_days: int, total_days: int, final_stock: Decimal ) -> str: """ Calculate stockout risk level. Args: stockout_days: Number of stockout days total_days: Total projection days final_stock: Final projected stock Returns: Risk level: 'low', 'medium', 'high', 'critical' """ if stockout_days == 0 and final_stock > Decimal('0'): return "low" stockout_ratio = stockout_days / total_days if total_days > 0 else 0 if stockout_ratio >= 0.5 or final_stock < Decimal('-100'): return "critical" elif stockout_ratio >= 0.3 or final_stock < Decimal('-50'): return "high" elif stockout_ratio > 0 or final_stock < Decimal('0'): return "medium" else: return "low" def get_high_risk_ingredients( self, projections: List[IngredientProjection] ) -> List[IngredientProjection]: """ Filter to high/critical risk ingredients. Args: projections: List of ingredient projections Returns: List of high-risk projections """ high_risk = [ p for p in projections if p.stockout_risk in ['high', 'critical'] ] if high_risk: logger.warning(f"Found {len(high_risk)} high-risk ingredients") for proj in high_risk: logger.warning( f" - {proj.ingredient_name}: {proj.stockout_days} stockout days, " f"risk={proj.stockout_risk}" ) return high_risk def get_summary_statistics( self, projections: List[IngredientProjection] ) -> Dict: """ Get summary statistics across all projections. Args: projections: List of ingredient projections Returns: Summary statistics """ total_ingredients = len(projections) stockout_ingredients = sum(1 for p in projections if p.stockout_days > 0) risk_breakdown = { 'low': sum(1 for p in projections if p.stockout_risk == 'low'), 'medium': sum(1 for p in projections if p.stockout_risk == 'medium'), 'high': sum(1 for p in projections if p.stockout_risk == 'high'), 'critical': sum(1 for p in projections if p.stockout_risk == 'critical') } total_stockout_days = sum(p.stockout_days for p in projections) total_consumption = sum(p.total_consumption for p in projections) total_receipts = sum(p.total_receipts for p in projections) return { 'total_ingredients': total_ingredients, 'stockout_ingredients': stockout_ingredients, 'stockout_percentage': (stockout_ingredients / total_ingredients * 100) if total_ingredients > 0 else 0, 'risk_breakdown': risk_breakdown, 'total_stockout_days': total_stockout_days, 'total_consumption': float(total_consumption), 'total_receipts': float(total_receipts), 'projection_horizon_days': self.projection_horizon_days } def export_projection_to_dict( self, projection: IngredientProjection ) -> Dict: """ Export projection to dictionary for API response. Args: projection: Ingredient projection Returns: Dictionary representation """ return { 'ingredient_id': projection.ingredient_id, 'ingredient_name': projection.ingredient_name, 'current_stock': float(projection.current_stock), 'unit_of_measure': projection.unit_of_measure, 'projection_horizon_days': projection.projection_horizon_days, 'total_consumption': float(projection.total_consumption), 'total_receipts': float(projection.total_receipts), 'stockout_days': projection.stockout_days, 'stockout_risk': projection.stockout_risk, 'daily_projections': [ { 'date': dp.date.isoformat(), 'starting_stock': float(dp.starting_stock), 'forecasted_consumption': float(dp.forecasted_consumption), 'scheduled_receipts': float(dp.scheduled_receipts), 'projected_ending_stock': float(dp.projected_ending_stock), 'is_stockout': dp.is_stockout, 'coverage_gap': float(dp.coverage_gap) } for dp in projection.daily_projections ] }