# services/recipes/app/repositories/recipe_repository.py """ Async recipe repository for database operations """ from typing import List, Optional, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, or_ from sqlalchemy.orm import selectinload from uuid import UUID from datetime import datetime import structlog from shared.database.repository import BaseRepository from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus from ..schemas.recipes import RecipeCreate, RecipeUpdate logger = structlog.get_logger() class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]): """Async repository for recipe operations""" def __init__(self, session: AsyncSession): super().__init__(Recipe, session) async def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]: """Get recipe with ingredients loaded""" result = await self.session.execute( select(Recipe) .options(selectinload(Recipe.ingredients)) .where(Recipe.id == recipe_id) ) recipe = result.scalar_one_or_none() if not recipe: return None return { "id": str(recipe.id), "tenant_id": str(recipe.tenant_id), "name": recipe.name, "recipe_code": recipe.recipe_code, "version": recipe.version, "finished_product_id": str(recipe.finished_product_id), "description": recipe.description, "category": recipe.category, "cuisine_type": recipe.cuisine_type, "difficulty_level": recipe.difficulty_level, "yield_quantity": recipe.yield_quantity, "yield_unit": recipe.yield_unit.value if recipe.yield_unit else None, "prep_time_minutes": recipe.prep_time_minutes, "cook_time_minutes": recipe.cook_time_minutes, "total_time_minutes": recipe.total_time_minutes, "rest_time_minutes": recipe.rest_time_minutes, "estimated_cost_per_unit": float(recipe.estimated_cost_per_unit) if recipe.estimated_cost_per_unit else None, "last_calculated_cost": float(recipe.last_calculated_cost) if recipe.last_calculated_cost else None, "cost_calculation_date": recipe.cost_calculation_date.isoformat() if recipe.cost_calculation_date else None, "target_margin_percentage": recipe.target_margin_percentage, "suggested_selling_price": float(recipe.suggested_selling_price) if recipe.suggested_selling_price else None, "instructions": recipe.instructions, "preparation_notes": recipe.preparation_notes, "storage_instructions": recipe.storage_instructions, "quality_standards": recipe.quality_standards, "serves_count": recipe.serves_count, "nutritional_info": recipe.nutritional_info, "allergen_info": recipe.allergen_info, "dietary_tags": recipe.dietary_tags, "batch_size_multiplier": recipe.batch_size_multiplier, "minimum_batch_size": recipe.minimum_batch_size, "maximum_batch_size": recipe.maximum_batch_size, "status": recipe.status, "is_seasonal": recipe.is_seasonal, "season_start_month": recipe.season_start_month, "season_end_month": recipe.season_end_month, "is_signature_item": recipe.is_signature_item, "created_at": recipe.created_at.isoformat() if recipe.created_at else None, "updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None, "ingredients": [ { "id": str(ingredient.id), "ingredient_id": str(ingredient.ingredient_id), "quantity": float(ingredient.quantity), "unit": ingredient.unit, "preparation_method": ingredient.preparation_method, "notes": ingredient.notes } for ingredient in recipe.ingredients ] if hasattr(recipe, 'ingredients') else [] } async def search_recipes( self, tenant_id: UUID, search_term: Optional[str] = None, status: Optional[str] = None, category: Optional[str] = None, is_seasonal: Optional[bool] = None, is_signature: Optional[bool] = None, difficulty_level: Optional[int] = None, limit: int = 100, offset: int = 0 ) -> List[Dict[str, Any]]: """Search recipes with multiple filters""" query = select(Recipe).where(Recipe.tenant_id == tenant_id) # Text search if search_term: query = query.where( or_( Recipe.name.ilike(f"%{search_term}%"), Recipe.description.ilike(f"%{search_term}%") ) ) # Status filter if status: query = query.where(Recipe.status == status) # Category filter if category: query = query.where(Recipe.category == category) # Seasonal filter if is_seasonal is not None: query = query.where(Recipe.is_seasonal == is_seasonal) # Signature filter if is_signature is not None: query = query.where(Recipe.is_signature_item == is_signature) # Difficulty filter if difficulty_level is not None: query = query.where(Recipe.difficulty_level == difficulty_level) # Apply ordering and pagination query = query.order_by(Recipe.name).limit(limit).offset(offset) result = await self.session.execute(query) recipes = result.scalars().all() return [ { "id": str(recipe.id), "tenant_id": str(recipe.tenant_id), "name": recipe.name, "recipe_code": recipe.recipe_code, "version": recipe.version, "finished_product_id": str(recipe.finished_product_id), "description": recipe.description, "category": recipe.category, "cuisine_type": recipe.cuisine_type, "difficulty_level": recipe.difficulty_level, "yield_quantity": recipe.yield_quantity, "yield_unit": recipe.yield_unit.value if recipe.yield_unit else None, "prep_time_minutes": recipe.prep_time_minutes, "cook_time_minutes": recipe.cook_time_minutes, "total_time_minutes": recipe.total_time_minutes, "rest_time_minutes": recipe.rest_time_minutes, "estimated_cost_per_unit": float(recipe.estimated_cost_per_unit) if recipe.estimated_cost_per_unit else None, "last_calculated_cost": float(recipe.last_calculated_cost) if recipe.last_calculated_cost else None, "cost_calculation_date": recipe.cost_calculation_date.isoformat() if recipe.cost_calculation_date else None, "target_margin_percentage": recipe.target_margin_percentage, "suggested_selling_price": float(recipe.suggested_selling_price) if recipe.suggested_selling_price else None, "instructions": recipe.instructions, "preparation_notes": recipe.preparation_notes, "storage_instructions": recipe.storage_instructions, "quality_standards": recipe.quality_standards, "serves_count": recipe.serves_count, "nutritional_info": recipe.nutritional_info, "allergen_info": recipe.allergen_info, "dietary_tags": recipe.dietary_tags, "batch_size_multiplier": recipe.batch_size_multiplier, "minimum_batch_size": recipe.minimum_batch_size, "maximum_batch_size": recipe.maximum_batch_size, "status": recipe.status, "is_seasonal": recipe.is_seasonal, "season_start_month": recipe.season_start_month, "season_end_month": recipe.season_end_month, "is_signature_item": recipe.is_signature_item, "created_at": recipe.created_at.isoformat() if recipe.created_at else None, "updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None } for recipe in recipes ] async def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]: """Get recipe statistics for dashboard""" # Total recipes total_result = await self.session.execute( select(func.count(Recipe.id)).where(Recipe.tenant_id == tenant_id) ) total_recipes = total_result.scalar() or 0 # Active recipes active_result = await self.session.execute( select(func.count(Recipe.id)).where( and_( Recipe.tenant_id == tenant_id, Recipe.status == RecipeStatus.ACTIVE ) ) ) active_recipes = active_result.scalar() or 0 # Signature recipes signature_result = await self.session.execute( select(func.count(Recipe.id)).where( and_( Recipe.tenant_id == tenant_id, Recipe.is_signature_item == True ) ) ) signature_recipes = signature_result.scalar() or 0 # Seasonal recipes seasonal_result = await self.session.execute( select(func.count(Recipe.id)).where( and_( Recipe.tenant_id == tenant_id, Recipe.is_seasonal == True ) ) ) seasonal_recipes = seasonal_result.scalar() or 0 # Category breakdown category_result = await self.session.execute( select(Recipe.category, func.count(Recipe.id)) .where(Recipe.tenant_id == tenant_id) .group_by(Recipe.category) ) category_data = category_result.all() # Convert to list of dicts for the schema category_breakdown = [ {"category": category or "Uncategorized", "count": count} for category, count in category_data ] return { "total_recipes": total_recipes, "active_recipes": active_recipes, "signature_recipes": signature_recipes, "seasonal_recipes": seasonal_recipes, "category_breakdown": category_breakdown }