Files
bakery-ia/services/recipes/app/services/recipe_service.py
2025-08-13 17:39:35 +02:00

374 lines
14 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.orm import Session
from ..repositories.recipe_repository import RecipeRepository, RecipeIngredientRepository
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
from .inventory_client import InventoryClient
from ..core.config import settings
logger = logging.getLogger(__name__)
class RecipeService:
"""Service for recipe management operations"""
def __init__(self, db: Session):
self.db = db
self.recipe_repo = RecipeRepository(db)
self.ingredient_repo = RecipeIngredientRepository(db)
self.inventory_client = InventoryClient()
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"""
try:
# Validate finished product exists in inventory
finished_product = await self.inventory_client.get_ingredient_by_id(
recipe_data["tenant_id"],
recipe_data["finished_product_id"]
)
if not finished_product:
return {
"success": False,
"error": "Finished product not found in inventory"
}
if finished_product.get("product_type") != "finished_product":
return {
"success": False,
"error": "Referenced item is not a finished product"
}
# Validate ingredients exist in inventory
ingredient_ids = [UUID(ing["ingredient_id"]) for ing in ingredients_data]
ingredients = await self.inventory_client.get_ingredients_by_ids(
recipe_data["tenant_id"],
ingredient_ids
)
if len(ingredients) != len(ingredient_ids):
return {
"success": False,
"error": "Some ingredients not found in inventory"
}
# Create recipe
recipe_data["created_by"] = created_by
recipe = self.recipe_repo.create(recipe_data)
# Create recipe ingredients
for ingredient_data in ingredients_data:
ingredient_data["tenant_id"] = recipe_data["tenant_id"]
ingredient_data["recipe_id"] = recipe.id
# Calculate cost if available
inventory_ingredient = next(
(ing for ing in ingredients if ing["id"] == ingredient_data["ingredient_id"]),
None
)
if inventory_ingredient and inventory_ingredient.get("average_cost"):
unit_cost = float(inventory_ingredient["average_cost"])
total_cost = unit_cost * ingredient_data["quantity"]
ingredient_data["unit_cost"] = unit_cost
ingredient_data["total_cost"] = total_cost
ingredient_data["cost_updated_at"] = datetime.utcnow()
self.ingredient_repo.create(ingredient_data)
# Calculate and update recipe cost
await self._update_recipe_cost(recipe.id)
return {
"success": True,
"data": recipe.to_dict()
}
except Exception as e:
logger.error(f"Error creating recipe: {e}")
self.db.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:
recipe = self.recipe_repo.get_by_id(recipe_id)
if not recipe:
return {
"success": False,
"error": "Recipe not found"
}
# Update recipe data
if updated_by:
recipe_data["updated_by"] = updated_by
updated_recipe = self.recipe_repo.update(recipe_id, recipe_data)
# Update ingredients if provided
if ingredients_data is not None:
# Validate ingredients exist in inventory
ingredient_ids = [UUID(ing["ingredient_id"]) for ing in ingredients_data]
ingredients = await self.inventory_client.get_ingredients_by_ids(
recipe.tenant_id,
ingredient_ids
)
if len(ingredients) != len(ingredient_ids):
return {
"success": False,
"error": "Some ingredients not found in inventory"
}
# Update ingredients
for ingredient_data in ingredients_data:
ingredient_data["tenant_id"] = recipe.tenant_id
# Calculate cost if available
inventory_ingredient = next(
(ing for ing in ingredients if ing["id"] == ingredient_data["ingredient_id"]),
None
)
if inventory_ingredient and inventory_ingredient.get("average_cost"):
unit_cost = float(inventory_ingredient["average_cost"])
total_cost = unit_cost * ingredient_data["quantity"]
ingredient_data["unit_cost"] = unit_cost
ingredient_data["total_cost"] = total_cost
ingredient_data["cost_updated_at"] = datetime.utcnow()
self.ingredient_repo.update_ingredients_for_recipe(recipe_id, ingredients_data)
# Recalculate recipe cost
await self._update_recipe_cost(recipe_id)
return {
"success": True,
"data": updated_recipe.to_dict()
}
except Exception as e:
logger.error(f"Error updating recipe {recipe_id}: {e}")
self.db.rollback()
return {
"success": False,
"error": str(e)
}
def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]:
"""Get recipe with all ingredients"""
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
if not recipe:
return None
recipe_dict = recipe.to_dict()
recipe_dict["ingredients"] = [ing.to_dict() for ing in recipe.ingredients]
return recipe_dict
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"""
recipe_status = RecipeStatus(status) if status else None
recipes = self.recipe_repo.search_recipes(
tenant_id=tenant_id,
search_term=search_term,
status=recipe_status,
category=category,
is_seasonal=is_seasonal,
is_signature=is_signature,
difficulty_level=difficulty_level,
limit=limit,
offset=offset
)
return [recipe.to_dict() for recipe in recipes]
def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get recipe statistics for dashboard"""
return self.recipe_repo.get_recipe_statistics(tenant_id)
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 = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
if not recipe:
return {
"success": False,
"error": "Recipe not found"
}
# Calculate required ingredients
required_ingredients = []
for ingredient in recipe.ingredients:
required_quantity = ingredient.quantity * batch_multiplier
required_ingredients.append({
"ingredient_id": str(ingredient.ingredient_id),
"required_quantity": required_quantity,
"unit": ingredient.unit.value
})
# Check availability with inventory service
availability_check = await self.inventory_client.check_ingredient_availability(
recipe.tenant_id,
required_ingredients
)
if not availability_check["success"]:
return availability_check
availability_data = availability_check["data"]
return {
"success": True,
"data": {
"recipe_id": str(recipe_id),
"recipe_name": recipe.name,
"batch_multiplier": batch_multiplier,
"feasible": availability_data.get("all_available", False),
"missing_ingredients": availability_data.get("missing_ingredients", []),
"insufficient_ingredients": availability_data.get("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:
new_recipe = self.recipe_repo.duplicate_recipe(recipe_id, new_name, created_by)
if not new_recipe:
return {
"success": False,
"error": "Recipe not found"
}
return {
"success": True,
"data": new_recipe.to_dict()
}
except Exception as e:
logger.error(f"Error duplicating recipe {recipe_id}: {e}")
self.db.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 is complete and valid
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
if not recipe:
return {
"success": False,
"error": "Recipe not found"
}
if not recipe.ingredients:
return {
"success": False,
"error": "Recipe must have at least one ingredient"
}
# Validate all ingredients exist in inventory
ingredient_ids = [ing.ingredient_id for ing in recipe.ingredients]
ingredients = await self.inventory_client.get_ingredients_by_ids(
recipe.tenant_id,
ingredient_ids
)
if len(ingredients) != len(ingredient_ids):
return {
"success": False,
"error": "Some recipe ingredients not found in inventory"
}
# Update recipe status
updated_recipe = self.recipe_repo.update(recipe_id, {
"status": RecipeStatus.ACTIVE,
"updated_by": activated_by
})
return {
"success": True,
"data": updated_recipe.to_dict()
}
except Exception as e:
logger.error(f"Error activating recipe {recipe_id}: {e}")
return {
"success": False,
"error": str(e)
}
async def _update_recipe_cost(self, recipe_id: UUID) -> None:
"""Update recipe cost based on ingredient costs"""
try:
total_cost = self.ingredient_repo.calculate_recipe_cost(recipe_id)
recipe = self.recipe_repo.get_by_id(recipe_id)
if recipe:
cost_per_unit = total_cost / recipe.yield_quantity if recipe.yield_quantity > 0 else 0
# Add overhead
overhead_cost = cost_per_unit * (settings.OVERHEAD_PERCENTAGE / 100)
total_cost_with_overhead = cost_per_unit + overhead_cost
# Calculate suggested selling price with target margin
if recipe.target_margin_percentage:
suggested_price = total_cost_with_overhead * (1 + recipe.target_margin_percentage / 100)
else:
suggested_price = total_cost_with_overhead * 1.3 # Default 30% margin
self.recipe_repo.update(recipe_id, {
"last_calculated_cost": total_cost_with_overhead,
"cost_calculation_date": datetime.utcnow(),
"suggested_selling_price": suggested_price
})
except Exception as e:
logger.error(f"Error updating recipe cost for {recipe_id}: {e}")