# 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": float(recipe.yield_quantity), "yield_unit": recipe.yield_unit.value if hasattr(recipe.yield_unit, 'value') else recipe.yield_unit, "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_check_configuration": recipe.quality_check_configuration, "serves_count": recipe.serves_count, "nutritional_info": recipe.nutritional_info, "allergen_info": recipe.allergen_info, "dietary_tags": recipe.dietary_tags, "batch_size_multiplier": float(recipe.batch_size_multiplier), "minimum_batch_size": float(recipe.minimum_batch_size) if recipe.minimum_batch_size else None, "maximum_batch_size": float(recipe.maximum_batch_size) if recipe.maximum_batch_size else None, "optimal_production_temperature": float(recipe.optimal_production_temperature) if recipe.optimal_production_temperature else None, "optimal_humidity": float(recipe.optimal_humidity) if recipe.optimal_humidity else None, "status": recipe.status.value if hasattr(recipe.status, 'value') else 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, "created_by": str(recipe.created_by) if recipe.created_by else None, "updated_by": str(recipe.updated_by) if hasattr(recipe, 'updated_by') and recipe.updated_by else None, "ingredients": [ { "id": str(ingredient.id), "tenant_id": str(ingredient.tenant_id), "recipe_id": str(ingredient.recipe_id), "ingredient_id": str(ingredient.ingredient_id), "quantity": float(ingredient.quantity), "unit": ingredient.unit.value if hasattr(ingredient.unit, 'value') else ingredient.unit, "quantity_in_base_unit": float(ingredient.quantity_in_base_unit) if ingredient.quantity_in_base_unit else None, "alternative_quantity": float(ingredient.alternative_quantity) if ingredient.alternative_quantity else None, "alternative_unit": ingredient.alternative_unit.value if hasattr(ingredient.alternative_unit, 'value') and ingredient.alternative_unit else None, "preparation_method": ingredient.preparation_method, "ingredient_notes": ingredient.ingredient_notes, "is_optional": ingredient.is_optional, "ingredient_order": ingredient.ingredient_order, "ingredient_group": ingredient.ingredient_group, "substitution_options": ingredient.substitution_options, "substitution_ratio": float(ingredient.substitution_ratio) if ingredient.substitution_ratio else None, "unit_cost": float(ingredient.unit_cost) if hasattr(ingredient, 'unit_cost') and ingredient.unit_cost else None, "total_cost": float(ingredient.total_cost) if hasattr(ingredient, 'total_cost') and ingredient.total_cost else None, "cost_updated_at": ingredient.cost_updated_at.isoformat() if hasattr(ingredient, 'cost_updated_at') and ingredient.cost_updated_at else None } 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": float(recipe.yield_quantity), "yield_unit": recipe.yield_unit.value if hasattr(recipe.yield_unit, 'value') else recipe.yield_unit, "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_check_configuration": recipe.quality_check_configuration, "serves_count": recipe.serves_count, "nutritional_info": recipe.nutritional_info, "allergen_info": recipe.allergen_info, "dietary_tags": recipe.dietary_tags, "batch_size_multiplier": float(recipe.batch_size_multiplier), "minimum_batch_size": float(recipe.minimum_batch_size) if recipe.minimum_batch_size else None, "maximum_batch_size": float(recipe.maximum_batch_size) if recipe.maximum_batch_size else None, "optimal_production_temperature": float(recipe.optimal_production_temperature) if recipe.optimal_production_temperature else None, "optimal_humidity": float(recipe.optimal_humidity) if recipe.optimal_humidity else None, "status": recipe.status.value if hasattr(recipe.status, 'value') else 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, "created_by": str(recipe.created_by) if recipe.created_by else None, "updated_by": str(recipe.updated_by) if hasattr(recipe, 'updated_by') and recipe.updated_by else None, "ingredients": [] # For list view, don't load ingredients to improve performance } 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 }