# services/recipes/app/services/recipe_service.py """ Service layer for recipe management operations """ import logging from typing import List, Optional, Dict, Any from uuid import UUID from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from ..repositories.recipe_repository import RecipeRepository from ..schemas.recipes import RecipeCreate, RecipeUpdate logger = logging.getLogger(__name__) class RecipeService: """Async service for recipe management operations""" def __init__(self, session: AsyncSession): self.session = session self.recipe_repo = RecipeRepository(session) async def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]: """Get recipe by ID with ingredients""" try: return await self.recipe_repo.get_recipe_with_ingredients(recipe_id) except Exception as e: logger.error(f"Error getting recipe {recipe_id}: {e}") return None 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 filters""" try: return await self.recipe_repo.search_recipes( tenant_id=tenant_id, search_term=search_term, status=status, category=category, is_seasonal=is_seasonal, is_signature=is_signature, difficulty_level=difficulty_level, limit=limit, offset=offset ) except Exception as e: logger.error(f"Error searching recipes: {e}") return [] async def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]: """Get recipe statistics for dashboard""" try: return await self.recipe_repo.get_recipe_statistics(tenant_id) except Exception as e: logger.error(f"Error getting recipe statistics: {e}") return {"total_recipes": 0, "active_recipes": 0, "signature_recipes": 0, "seasonal_recipes": 0} async def get_deletion_summary(self, recipe_id: UUID) -> Dict[str, Any]: """Get summary of what will be affected by deleting this recipe""" try: from sqlalchemy import select, func from ..models.recipes import RecipeIngredient # Get recipe info recipe = await self.recipe_repo.get_by_id(recipe_id) if not recipe: return {"success": False, "error": "Recipe not found"} # Count recipe ingredients ingredients_result = await self.session.execute( select(func.count(RecipeIngredient.id)) .where(RecipeIngredient.recipe_id == recipe_id) ) ingredients_count = ingredients_result.scalar() or 0 # Count production batches using this recipe (if production tables exist) production_batches_count = 0 try: # Try to import production models if they exist production_batches_result = await self.session.execute( select(func.count()).select_from( select(1).where( # This would need actual production_batches table reference # For now, set to 0 ).subquery() ) ) production_batches_count = 0 # Set to 0 for now except: production_batches_count = 0 # Count dependent recipes (recipes using this as ingredient) - future feature dependent_recipes_count = 0 # Count affected orders - would need orders service integration affected_orders_count = 0 # Determine if deletion is safe warnings = [] can_delete = True if production_batches_count > 0: warnings.append(f"Esta receta tiene {production_batches_count} lotes de producción asociados") can_delete = False if affected_orders_count > 0: warnings.append(f"Esta receta está en {affected_orders_count} pedidos") can_delete = False if dependent_recipes_count > 0: warnings.append(f"{dependent_recipes_count} recetas dependen de esta") if recipe.status == RecipeStatus.ACTIVE: warnings.append("Esta receta está activa. Considera archivarla primero.") return { "success": True, "data": { "recipe_id": str(recipe.id), "recipe_name": recipe.name, "recipe_code": recipe.recipe_code or "", "production_batches_count": production_batches_count, "recipe_ingredients_count": ingredients_count, "dependent_recipes_count": dependent_recipes_count, "affected_orders_count": affected_orders_count, "last_used_date": None, "can_delete": can_delete, "warnings": warnings } } except Exception as e: logger.error(f"Error getting deletion summary: {e}") return {"success": False, "error": str(e)} async def create_recipe( self, recipe_data: Dict[str, Any], ingredients_data: List[Dict[str, Any]], created_by: UUID ) -> Dict[str, Any]: """Create a new recipe with ingredients""" from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus try: # Add metadata recipe_data["created_by"] = created_by recipe_data["created_at"] = datetime.utcnow() recipe_data["updated_at"] = datetime.utcnow() recipe_data["status"] = recipe_data.get("status", RecipeStatus.DRAFT) # Create Recipe model directly (without ingredients) recipe = Recipe(**recipe_data) self.session.add(recipe) await self.session.flush() # Get the recipe ID # Now create ingredients with the recipe_id and tenant_id for ing_data in ingredients_data: ingredient = RecipeIngredient( recipe_id=recipe.id, tenant_id=recipe.tenant_id, # Add tenant_id from recipe **ing_data ) self.session.add(ingredient) await self.session.flush() # Commit the transaction to persist changes await self.session.commit() # Get the created recipe with ingredients result = await self.recipe_repo.get_recipe_with_ingredients(recipe.id) return { "success": True, "data": result } except Exception as e: logger.error(f"Error creating recipe: {e}") await self.session.rollback() return { "success": False, "error": str(e) } async def update_recipe( self, recipe_id: UUID, recipe_data: Dict[str, Any], ingredients_data: Optional[List[Dict[str, Any]]] = None, updated_by: UUID = None ) -> Dict[str, Any]: """Update an existing recipe""" try: # Check if recipe exists existing_recipe = await self.recipe_repo.get_by_id(recipe_id) if not existing_recipe: return { "success": False, "error": "Recipe not found" } # Status transition business rules if "status" in recipe_data: from ..models.recipes import RecipeStatus new_status = recipe_data["status"] current_status = existing_recipe.status # Cannot reactivate discontinued recipes if current_status == RecipeStatus.DISCONTINUED: if new_status != RecipeStatus.DISCONTINUED: return { "success": False, "error": "Cannot reactivate a discontinued recipe. Create a new version instead." } # Can only archive active or testing recipes if new_status == RecipeStatus.ARCHIVED: if current_status not in [RecipeStatus.ACTIVE, RecipeStatus.TESTING]: return { "success": False, "error": "Can only archive active or testing recipes." } # Cannot activate drafts without ingredients if new_status == RecipeStatus.ACTIVE and current_status == RecipeStatus.DRAFT: # Check if recipe has ingredients from sqlalchemy import select, func from ..models.recipes import RecipeIngredient result = await self.session.execute( select(func.count(RecipeIngredient.id)).where(RecipeIngredient.recipe_id == recipe_id) ) ingredient_count = result.scalar() if ingredient_count == 0: return { "success": False, "error": "Cannot activate a recipe without ingredients." } # Add metadata if updated_by: recipe_data["updated_by"] = updated_by recipe_data["updated_at"] = datetime.utcnow() # Use the shared repository's update method recipe_update = RecipeUpdate(**recipe_data) updated_recipe = await self.recipe_repo.update(recipe_id, recipe_update) # Get the updated recipe with ingredients result = await self.recipe_repo.get_recipe_with_ingredients(recipe_id) return { "success": True, "data": result } except Exception as e: logger.error(f"Error updating recipe {recipe_id}: {e}") await self.session.rollback() return { "success": False, "error": str(e) } async def delete_recipe(self, recipe_id: UUID) -> bool: """Delete a recipe""" try: return await self.recipe_repo.delete(recipe_id) except Exception as e: logger.error(f"Error deleting recipe {recipe_id}: {e}") return False async def check_recipe_feasibility(self, recipe_id: UUID, batch_multiplier: float = 1.0) -> Dict[str, Any]: """Check if recipe can be produced with current inventory""" try: recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id) if not recipe: return { "success": False, "error": "Recipe not found" } # Simplified feasibility check - can be enhanced later with inventory service integration return { "success": True, "data": { "recipe_id": str(recipe_id), "recipe_name": recipe["name"], "batch_multiplier": batch_multiplier, "feasible": True, "missing_ingredients": [], "insufficient_ingredients": [] } } except Exception as e: logger.error(f"Error checking recipe feasibility {recipe_id}: {e}") return { "success": False, "error": str(e) } async def duplicate_recipe( self, recipe_id: UUID, new_name: str, created_by: UUID ) -> Dict[str, Any]: """Create a duplicate of an existing recipe""" try: # Get original recipe original_recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id) if not original_recipe: return { "success": False, "error": "Recipe not found" } # Create new recipe data new_recipe_data = original_recipe.copy() new_recipe_data["name"] = new_name # Remove fields that should be auto-generated new_recipe_data.pop("id", None) new_recipe_data.pop("created_at", None) new_recipe_data.pop("updated_at", None) # Handle ingredients ingredients = new_recipe_data.pop("ingredients", []) # Create the duplicate result = await self.create_recipe(new_recipe_data, ingredients, created_by) return result except Exception as e: logger.error(f"Error duplicating recipe {recipe_id}: {e}") await self.session.rollback() return { "success": False, "error": str(e) } async def activate_recipe(self, recipe_id: UUID, activated_by: UUID) -> Dict[str, Any]: """Activate a recipe for production""" try: # Check if recipe exists recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id) if not recipe: return { "success": False, "error": "Recipe not found" } if not recipe.get("ingredients"): return { "success": False, "error": "Recipe must have at least one ingredient" } # Update recipe status update_data = { "status": "active", "updated_by": activated_by, "updated_at": datetime.utcnow() } recipe_update = RecipeUpdate(**update_data) await self.recipe_repo.update(recipe_id, recipe_update) # Get the updated recipe result = await self.recipe_repo.get_recipe_with_ingredients(recipe_id) return { "success": True, "data": result } except Exception as e: logger.error(f"Error activating recipe {recipe_id}: {e}") return { "success": False, "error": str(e) } # Quality Configuration Methods async def update_recipe_quality_configuration( self, tenant_id: UUID, recipe_id: UUID, quality_config_update: Dict[str, Any], user_id: UUID ) -> Dict[str, Any]: """Update quality configuration for a recipe""" try: # Get current recipe recipe = await self.recipe_repo.get_recipe(tenant_id, recipe_id) if not recipe: raise ValueError("Recipe not found") # Get existing quality configuration or create default current_config = recipe.get("quality_check_configuration", { "stages": {}, "overall_quality_threshold": 7.0, "critical_stage_blocking": True, "auto_create_quality_checks": True, "quality_manager_approval_required": False }) # Merge with updates if "stages" in quality_config_update: current_config["stages"].update(quality_config_update["stages"]) for key in ["overall_quality_threshold", "critical_stage_blocking", "auto_create_quality_checks", "quality_manager_approval_required"]: if key in quality_config_update: current_config[key] = quality_config_update[key] # Update recipe with new configuration recipe_update = RecipeUpdate(quality_check_configuration=current_config) await self.recipe_repo.update_recipe(tenant_id, recipe_id, recipe_update, user_id) # Return updated recipe updated_recipe = await self.recipe_repo.get_recipe(tenant_id, recipe_id) return updated_recipe except Exception as e: logger.error(f"Error updating recipe quality configuration: {e}") raise async def add_quality_templates_to_stage( self, tenant_id: UUID, recipe_id: UUID, stage: str, template_ids: List[UUID], user_id: UUID ): """Add quality templates to a specific recipe stage""" try: # Get current recipe recipe = await self.recipe_repo.get_recipe(tenant_id, recipe_id) if not recipe: raise ValueError("Recipe not found") # Get existing quality configuration quality_config = recipe.get("quality_check_configuration", {"stages": {}}) # Initialize stage if it doesn't exist if stage not in quality_config["stages"]: quality_config["stages"][stage] = { "template_ids": [], "required_checks": [], "optional_checks": [], "blocking_on_failure": True, "min_quality_score": None } # Add template IDs (avoid duplicates) stage_config = quality_config["stages"][stage] existing_ids = set(stage_config.get("template_ids", [])) new_ids = [str(tid) for tid in template_ids if str(tid) not in existing_ids] stage_config["template_ids"].extend(new_ids) # Update recipe recipe_update = RecipeUpdate(quality_check_configuration=quality_config) await self.recipe_repo.update_recipe(tenant_id, recipe_id, recipe_update, user_id) except Exception as e: logger.error(f"Error adding quality templates to stage: {e}") raise async def remove_quality_template_from_stage( self, tenant_id: UUID, recipe_id: UUID, stage: str, template_id: UUID, user_id: UUID ): """Remove a quality template from a specific recipe stage""" try: # Get current recipe recipe = await self.recipe_repo.get_recipe(tenant_id, recipe_id) if not recipe: raise ValueError("Recipe not found") # Get existing quality configuration quality_config = recipe.get("quality_check_configuration", {"stages": {}}) # Remove template ID from stage if stage in quality_config["stages"]: stage_config = quality_config["stages"][stage] template_ids = stage_config.get("template_ids", []) template_ids = [tid for tid in template_ids if str(tid) != str(template_id)] stage_config["template_ids"] = template_ids # Update recipe recipe_update = RecipeUpdate(quality_check_configuration=quality_config) await self.recipe_repo.update_recipe(tenant_id, recipe_id, recipe_update, user_id) except Exception as e: logger.error(f"Error removing quality template from stage: {e}") raise