374 lines
14 KiB
Python
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}") |