# 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 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 logger = structlog.get_logger() class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]): """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 # 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'): create_data['total_cost'] = create_data['unit_cost'] * create_data['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""" try: expiry_date = datetime.now() + timedelta(days=days_ahead) 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.expiration_date.isnot(None), Stock.expiration_date <= expiry_date ) ) .order_by(asc(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""" try: current_date = datetime.now() 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.expiration_date.isnot(None), Stock.expiration_date < current_date ) ) .order_by(desc(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""" try: # Get available stock ordered by expiration date order_clause = asc(Stock.expiration_date) if fifo else desc(Stock.expiration_date) 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: # Total stock value and counts 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'), func.sum( func.case( (Stock.expiration_date < datetime.now(), 1), else_=0 ) ).label('expired_items'), func.sum( func.case( (and_(Stock.expiration_date.isnot(None), Stock.expiration_date <= datetime.now() + timedelta(days=7)), 1), else_=0 ) ).label('expiring_soon_items') ).where( and_( Stock.tenant_id == tenant_id, Stock.is_available == True ) ) ) summary = result.first() return { 'total_stock_items': summary.total_stock_items or 0, 'total_stock_value': float(summary.total_stock_value) if summary.total_stock_value else 0.0, 'unique_ingredients': summary.unique_ingredients or 0, 'expired_items': summary.expired_items or 0, 'expiring_soon_items': summary.expiring_soon_items or 0 } 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""" try: current_date = datetime.now() result = await self.session.execute( update(Stock) .where( and_( Stock.tenant_id == tenant_id, Stock.expiration_date < current_date, Stock.is_expired == False ) ) .values(is_expired=True, quality_status="expired") ) expired_count = result.rowcount logger.info(f"Marked {expired_count} stock items as expired", 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