# services/inventory/app/repositories/stock_repository.py """ Stock Repository using Repository Pattern """ from typing import List, Optional, Dict, Any, Tuple from uuid import UUID from datetime import datetime, timedelta from decimal import Decimal from sqlalchemy import select, func, and_, or_, desc, asc, update from sqlalchemy.ext.asyncio import AsyncSession import structlog from app.models.inventory import Stock, Ingredient from app.schemas.inventory import StockCreate, StockUpdate from shared.database.repository import BaseRepository from shared.utils.batch_generator import BatchCountProvider logger = structlog.get_logger() class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCountProvider): """Repository for stock operations""" def __init__(self, session: AsyncSession): super().__init__(Stock, session) async def create_stock_entry(self, stock_data: StockCreate, tenant_id: UUID) -> Stock: """Create a new stock entry""" try: # Prepare data create_data = stock_data.model_dump() create_data['tenant_id'] = tenant_id # Ensure production_stage is properly converted to enum value if 'production_stage' in create_data: if hasattr(create_data['production_stage'], 'value'): create_data['production_stage'] = create_data['production_stage'].value elif isinstance(create_data['production_stage'], str): # If it's a string, ensure it's the correct enum value from app.models.inventory import ProductionStage try: enum_obj = ProductionStage[create_data['production_stage']] create_data['production_stage'] = enum_obj.value except KeyError: # If it's already the value, keep it as is pass # Calculate available quantity available_qty = create_data['current_quantity'] - create_data.get('reserved_quantity', 0) create_data['available_quantity'] = max(0, available_qty) # Calculate total cost if unit cost provided if create_data.get('unit_cost') and create_data.get('current_quantity'): unit_cost = create_data['unit_cost'] current_quantity = Decimal(str(create_data['current_quantity'])) create_data['total_cost'] = unit_cost * current_quantity # Create record record = await self.create(create_data) logger.info( "Created stock entry", stock_id=record.id, ingredient_id=record.ingredient_id, quantity=record.current_quantity, tenant_id=tenant_id ) return record except Exception as e: logger.error("Failed to create stock entry", error=str(e), tenant_id=tenant_id) raise async def get_stock_by_ingredient( self, tenant_id: UUID, ingredient_id: UUID, include_unavailable: bool = False ) -> List[Stock]: """Get all stock entries for a specific ingredient""" try: query = select(self.model).where( and_( self.model.tenant_id == tenant_id, self.model.ingredient_id == ingredient_id ) ) if not include_unavailable: query = query.where(self.model.is_available == True) query = query.order_by(asc(self.model.expiration_date)) result = await self.session.execute(query) return result.scalars().all() except Exception as e: logger.error("Failed to get stock by ingredient", error=str(e), ingredient_id=ingredient_id) raise async def get_total_stock_by_ingredient(self, tenant_id: UUID, ingredient_id: UUID) -> Dict[str, float]: """Get total stock quantities for an ingredient""" try: result = await self.session.execute( select( func.coalesce(func.sum(Stock.current_quantity), 0).label('total_quantity'), func.coalesce(func.sum(Stock.reserved_quantity), 0).label('total_reserved'), func.coalesce(func.sum(Stock.available_quantity), 0).label('total_available') ).where( and_( Stock.tenant_id == tenant_id, Stock.ingredient_id == ingredient_id, Stock.is_available == True ) ) ) row = result.first() return { 'total_quantity': float(row.total_quantity) if row.total_quantity else 0.0, 'total_reserved': float(row.total_reserved) if row.total_reserved else 0.0, 'total_available': float(row.total_available) if row.total_available else 0.0 } except Exception as e: logger.error("Failed to get total stock", error=str(e), ingredient_id=ingredient_id) raise async def get_expiring_stock( self, tenant_id: UUID, days_ahead: int = 7 ) -> List[Tuple[Stock, Ingredient]]: """Get stock items expiring within specified days using state-dependent expiration logic""" try: expiry_date = datetime.now() + timedelta(days=days_ahead) # Use final_expiration_date if available (for transformed products), # otherwise use regular expiration_date result = await self.session.execute( select(Stock, Ingredient) .join(Ingredient, Stock.ingredient_id == Ingredient.id) .where( and_( Stock.tenant_id == tenant_id, Stock.is_available == True, or_( and_( Stock.final_expiration_date.isnot(None), Stock.final_expiration_date <= expiry_date ), and_( Stock.final_expiration_date.is_(None), Stock.expiration_date.isnot(None), Stock.expiration_date <= expiry_date ) ) ) ) .order_by( asc( func.coalesce(Stock.final_expiration_date, Stock.expiration_date) ) ) ) return result.all() except Exception as e: logger.error("Failed to get expiring stock", error=str(e), tenant_id=tenant_id) raise async def get_expired_stock(self, tenant_id: UUID) -> List[Tuple[Stock, Ingredient]]: """Get stock items that have expired using state-dependent expiration logic""" try: current_date = datetime.now() # Use final_expiration_date if available (for transformed products), # otherwise use regular expiration_date result = await self.session.execute( select(Stock, Ingredient) .join(Ingredient, Stock.ingredient_id == Ingredient.id) .where( and_( Stock.tenant_id == tenant_id, Stock.is_available == True, or_( and_( Stock.final_expiration_date.isnot(None), Stock.final_expiration_date < current_date ), and_( Stock.final_expiration_date.is_(None), Stock.expiration_date.isnot(None), Stock.expiration_date < current_date ) ) ) ) .order_by( desc( func.coalesce(Stock.final_expiration_date, Stock.expiration_date) ) ) ) return result.all() except Exception as e: logger.error("Failed to get expired stock", error=str(e), tenant_id=tenant_id) raise async def reserve_stock( self, tenant_id: UUID, ingredient_id: UUID, quantity: float, fifo: bool = True ) -> List[Dict[str, Any]]: """Reserve stock using FIFO/LIFO method with state-dependent expiration""" try: # Order by appropriate expiration date based on transformation status effective_expiration = func.coalesce(Stock.final_expiration_date, Stock.expiration_date) order_clause = asc(effective_expiration) if fifo else desc(effective_expiration) result = await self.session.execute( select(Stock).where( and_( Stock.tenant_id == tenant_id, Stock.ingredient_id == ingredient_id, Stock.is_available == True, Stock.available_quantity > 0 ) ).order_by(order_clause) ) stock_items = result.scalars().all() reservations = [] remaining_qty = quantity for stock_item in stock_items: if remaining_qty <= 0: break available = stock_item.available_quantity to_reserve = min(remaining_qty, available) # Update stock reservation new_reserved = stock_item.reserved_quantity + to_reserve new_available = stock_item.current_quantity - new_reserved await self.session.execute( update(Stock) .where(Stock.id == stock_item.id) .values( reserved_quantity=new_reserved, available_quantity=new_available ) ) reservations.append({ 'stock_id': stock_item.id, 'reserved_quantity': to_reserve, 'batch_number': stock_item.batch_number, 'expiration_date': stock_item.expiration_date }) remaining_qty -= to_reserve if remaining_qty > 0: logger.warning( "Insufficient stock for reservation", ingredient_id=ingredient_id, requested=quantity, unfulfilled=remaining_qty ) return reservations except Exception as e: logger.error("Failed to reserve stock", error=str(e), ingredient_id=ingredient_id) raise async def release_stock_reservation( self, stock_id: UUID, quantity: float ) -> Optional[Stock]: """Release reserved stock""" try: stock_item = await self.get_by_id(stock_id) if not stock_item: return None # Calculate new quantities new_reserved = max(0, stock_item.reserved_quantity - quantity) new_available = stock_item.current_quantity - new_reserved # Update stock await self.session.execute( update(Stock) .where(Stock.id == stock_id) .values( reserved_quantity=new_reserved, available_quantity=new_available ) ) # Refresh and return updated stock await self.session.refresh(stock_item) return stock_item except Exception as e: logger.error("Failed to release stock reservation", error=str(e), stock_id=stock_id) raise async def consume_stock( self, stock_id: UUID, quantity: float, from_reserved: bool = True ) -> Optional[Stock]: """Consume stock (reduce current quantity)""" try: stock_item = await self.get_by_id(stock_id) if not stock_item: return None if from_reserved: # Reduce from reserved quantity new_reserved = max(0, stock_item.reserved_quantity - quantity) new_current = max(0, stock_item.current_quantity - quantity) new_available = new_current - new_reserved else: # Reduce from available quantity new_current = max(0, stock_item.current_quantity - quantity) new_available = max(0, stock_item.available_quantity - quantity) new_reserved = stock_item.reserved_quantity # Update stock await self.session.execute( update(Stock) .where(Stock.id == stock_id) .values( current_quantity=new_current, reserved_quantity=new_reserved, available_quantity=new_available, is_available=new_current > 0 ) ) # Refresh and return updated stock await self.session.refresh(stock_item) return stock_item except Exception as e: logger.error("Failed to consume stock", error=str(e), stock_id=stock_id) raise async def get_stock_summary_by_tenant(self, tenant_id: UUID) -> Dict[str, Any]: """Get stock summary for tenant dashboard""" try: # Basic stock summary basic_result = await self.session.execute( select( func.count(Stock.id).label('total_stock_items'), func.coalesce(func.sum(Stock.total_cost), 0).label('total_stock_value'), func.count(func.distinct(Stock.ingredient_id)).label('unique_ingredients') ).where( and_( Stock.tenant_id == tenant_id, Stock.is_available == True ) ) ) basic_summary = basic_result.first() # Count expired items expired_result = await self.session.execute( select(func.count(Stock.id)).where( and_( Stock.tenant_id == tenant_id, Stock.is_available == True, Stock.expiration_date < datetime.now() ) ) ) expired_count = expired_result.scalar() or 0 # Count expiring soon items expiring_result = await self.session.execute( select(func.count(Stock.id)).where( and_( Stock.tenant_id == tenant_id, Stock.is_available == True, Stock.expiration_date.isnot(None), Stock.expiration_date <= datetime.now() + timedelta(days=7) ) ) ) expiring_count = expiring_result.scalar() or 0 return { 'total_stock_items': basic_summary.total_stock_items or 0, 'total_stock_value': float(basic_summary.total_stock_value) if basic_summary.total_stock_value else 0.0, 'unique_ingredients': basic_summary.unique_ingredients or 0, 'expired_items': expired_count, 'expiring_soon_items': expiring_count } except Exception as e: logger.error("Failed to get stock summary", error=str(e), tenant_id=tenant_id) raise async def mark_expired_stock(self, tenant_id: UUID) -> int: """Mark expired stock items as expired using state-dependent expiration logic""" try: current_date = datetime.now() # Mark items as expired based on final_expiration_date or expiration_date result = await self.session.execute( update(Stock) .where( and_( Stock.tenant_id == tenant_id, Stock.is_expired == False, or_( and_( Stock.final_expiration_date.isnot(None), Stock.final_expiration_date < current_date ), and_( Stock.final_expiration_date.is_(None), Stock.expiration_date.isnot(None), Stock.expiration_date < current_date ) ) ) ) .values(is_expired=True, quality_status="expired") ) expired_count = result.rowcount logger.info(f"Marked {expired_count} stock items as expired using state-dependent logic", tenant_id=tenant_id) return expired_count except Exception as e: logger.error("Failed to mark expired stock", error=str(e), tenant_id=tenant_id) raise async def get_stock_by_production_stage( self, tenant_id: UUID, production_stage: 'ProductionStage', ingredient_id: Optional[UUID] = None ) -> List['Stock']: """Get stock items by production stage""" try: conditions = [ Stock.tenant_id == tenant_id, Stock.production_stage == production_stage, Stock.is_available == True ] if ingredient_id: conditions.append(Stock.ingredient_id == ingredient_id) result = await self.session.execute( select(Stock) .where(and_(*conditions)) .order_by(asc(func.coalesce(Stock.final_expiration_date, Stock.expiration_date))) ) return result.scalars().all() except Exception as e: logger.error("Failed to get stock by production stage", error=str(e), production_stage=production_stage) raise async def get_stock_entries( self, tenant_id: UUID, skip: int = 0, limit: int = 100, ingredient_id: Optional[UUID] = None, available_only: bool = True ) -> List[Stock]: """Get stock entries with filtering and pagination""" try: conditions = [Stock.tenant_id == tenant_id] if available_only: conditions.append(Stock.is_available == True) if ingredient_id: conditions.append(Stock.ingredient_id == ingredient_id) query = ( select(Stock) .where(and_(*conditions)) .order_by(desc(Stock.created_at)) .offset(skip) .limit(limit) ) result = await self.session.execute(query) return result.scalars().all() except Exception as e: logger.error("Failed to get stock entries", error=str(e), tenant_id=tenant_id) raise async def delete_by_ingredient(self, ingredient_id: UUID, tenant_id: UUID) -> int: """Delete all stock entries for a specific ingredient""" try: from sqlalchemy import delete stmt = delete(Stock).where( and_( Stock.ingredient_id == ingredient_id, Stock.tenant_id == tenant_id ) ) result = await self.session.execute(stmt) deleted_count = result.rowcount logger.info( "Deleted stock entries for ingredient", ingredient_id=str(ingredient_id), tenant_id=str(tenant_id), deleted_count=deleted_count ) return deleted_count except Exception as e: logger.error( "Failed to delete stock entries for ingredient", error=str(e), ingredient_id=str(ingredient_id), tenant_id=str(tenant_id) ) raise async def get_daily_batch_count( self, tenant_id: str, date_start: datetime, date_end: datetime, prefix: Optional[str] = None ) -> int: """Get the count of batches created today for the given tenant""" try: conditions = [ Stock.tenant_id == tenant_id, Stock.created_at >= date_start, Stock.created_at <= date_end ] if prefix: conditions.append(Stock.batch_number.like(f"{prefix}-%")) stmt = select(func.count(Stock.id)).where(and_(*conditions)) result = await self.session.execute(stmt) count = result.scalar() or 0 logger.debug( "Retrieved daily batch count", tenant_id=tenant_id, prefix=prefix, count=count, date_start=date_start, date_end=date_end ) return count except Exception as e: logger.error( "Failed to get daily batch count", error=str(e), tenant_id=tenant_id, prefix=prefix ) raise async def get_expired_batches_for_processing(self, tenant_id: UUID) -> List[Tuple[Stock, Ingredient]]: """Get expired batches that haven't been processed yet (for automatic processing)""" try: current_date = datetime.now() # Find expired batches that are still available and not yet marked as expired result = await self.session.execute( select(Stock, Ingredient) .join(Ingredient, Stock.ingredient_id == Ingredient.id) .where( and_( Stock.tenant_id == tenant_id, Stock.is_available == True, Stock.is_expired == False, Stock.current_quantity > 0, or_( and_( Stock.final_expiration_date.isnot(None), Stock.final_expiration_date <= current_date ), and_( Stock.final_expiration_date.is_(None), Stock.expiration_date.isnot(None), Stock.expiration_date <= current_date ) ) ) ) .order_by( asc(func.coalesce(Stock.final_expiration_date, Stock.expiration_date)) ) ) expired_batches = result.all() logger.info("Found expired batches for processing", tenant_id=str(tenant_id), count=len(expired_batches)) return expired_batches except Exception as e: logger.error("Failed to get expired batches for processing", error=str(e), tenant_id=tenant_id) raise async def mark_batch_as_expired(self, stock_id: UUID, tenant_id: UUID) -> bool: """Mark a specific batch as expired and unavailable""" try: result = await self.session.execute( update(Stock) .where( and_( Stock.id == stock_id, Stock.tenant_id == tenant_id ) ) .values( is_expired=True, is_available=False, quality_status="expired", updated_at=datetime.now() ) ) if result.rowcount > 0: logger.info("Marked batch as expired", stock_id=str(stock_id), tenant_id=str(tenant_id)) return True else: logger.warning("No batch found to mark as expired", stock_id=str(stock_id), tenant_id=str(tenant_id)) return False except Exception as e: logger.error("Failed to mark batch as expired", error=str(e), stock_id=str(stock_id), tenant_id=str(tenant_id)) raise async def update_stock_to_zero(self, stock_id: UUID, tenant_id: UUID) -> bool: """Update stock quantities to zero after moving to waste""" try: result = await self.session.execute( update(Stock) .where( and_( Stock.id == stock_id, Stock.tenant_id == tenant_id ) ) .values( current_quantity=0, available_quantity=0, updated_at=datetime.now() ) ) if result.rowcount > 0: logger.info("Updated stock quantities to zero", stock_id=str(stock_id), tenant_id=str(tenant_id)) return True else: logger.warning("No stock found to update to zero", stock_id=str(stock_id), tenant_id=str(tenant_id)) return False except Exception as e: logger.error("Failed to update stock to zero", error=str(e), stock_id=str(stock_id), tenant_id=str(tenant_id)) raise