519 lines
19 KiB
Python
519 lines
19 KiB
Python
# 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 |