Create the frontend receipes page to use real API
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
# services/recipes/app/services/__init__.py
|
||||
|
||||
from .recipe_service import RecipeService
|
||||
from .production_service import ProductionService
|
||||
from .inventory_client import InventoryClient
|
||||
|
||||
__all__ = [
|
||||
"RecipeService",
|
||||
"ProductionService",
|
||||
"InventoryClient"
|
||||
"RecipeService"
|
||||
]
|
||||
@@ -1,151 +0,0 @@
|
||||
# services/recipes/app/services/inventory_client.py
|
||||
"""
|
||||
Client for communicating with Inventory Service
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from shared.clients.inventory_client import InventoryServiceClient as SharedInventoryClient
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InventoryClient:
|
||||
"""Client for inventory service communication via shared client"""
|
||||
|
||||
def __init__(self):
|
||||
self._shared_client = SharedInventoryClient(settings)
|
||||
|
||||
async def get_ingredient_by_id(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get ingredient details from inventory service"""
|
||||
try:
|
||||
result = await self._shared_client.get_ingredient_by_id(ingredient_id, str(tenant_id))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ingredient {ingredient_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_ingredients_by_ids(self, tenant_id: UUID, ingredient_ids: List[UUID]) -> List[Dict[str, Any]]:
|
||||
"""Get multiple ingredients by IDs"""
|
||||
try:
|
||||
# For now, get ingredients individually - could be optimized with batch endpoint
|
||||
results = []
|
||||
for ingredient_id in ingredient_ids:
|
||||
ingredient = await self._shared_client.get_ingredient_by_id(ingredient_id, str(tenant_id))
|
||||
if ingredient:
|
||||
results.append(ingredient)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ingredients batch: {e}")
|
||||
return []
|
||||
|
||||
async def get_ingredient_stock_level(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get current stock level for ingredient"""
|
||||
try:
|
||||
stock_entries = await self._shared_client.get_ingredient_stock(ingredient_id, str(tenant_id))
|
||||
if stock_entries:
|
||||
# Calculate total available stock from all entries
|
||||
total_stock = sum(entry.get('available_quantity', 0) for entry in stock_entries)
|
||||
return {
|
||||
'ingredient_id': str(ingredient_id),
|
||||
'total_available': total_stock,
|
||||
'stock_entries': stock_entries
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stock level for {ingredient_id}: {e}")
|
||||
return None
|
||||
|
||||
async def reserve_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
reservations: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Reserve ingredients for production"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/stock/reserve",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json={"reservations": reservations}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
logger.error(f"Failed to reserve ingredients: {response.status_code}")
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reserving ingredients: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def consume_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
consumptions: List[Dict[str, Any]],
|
||||
production_batch_id: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Record ingredient consumption for production"""
|
||||
try:
|
||||
consumption_data = {
|
||||
"consumptions": consumptions,
|
||||
"reference_number": str(production_batch_id),
|
||||
"movement_type": "production_use"
|
||||
}
|
||||
|
||||
result = await self._shared_client.consume_stock(consumption_data, str(tenant_id))
|
||||
|
||||
if result:
|
||||
return {"success": True, "data": result}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to consume ingredients"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error consuming ingredients: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def add_finished_product_to_inventory(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
product_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Add finished product to inventory after production"""
|
||||
try:
|
||||
result = await self._shared_client.receive_stock(product_data, str(tenant_id))
|
||||
|
||||
if result:
|
||||
return {"success": True, "data": result}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to add finished product"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding finished product: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def check_ingredient_availability(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
required_ingredients: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Check if required ingredients are available for production"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/stock/check-availability",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json={"required_ingredients": required_ingredients}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
logger.error(f"Failed to check availability: {response.status_code}")
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking availability: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1,401 +0,0 @@
|
||||
# services/recipes/app/services/production_service.py
|
||||
"""
|
||||
Service layer for production management operations
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..repositories.production_repository import (
|
||||
ProductionRepository,
|
||||
ProductionIngredientConsumptionRepository,
|
||||
ProductionScheduleRepository
|
||||
)
|
||||
from ..repositories.recipe_repository import RecipeRepository
|
||||
from ..models.recipes import ProductionBatch, ProductionStatus, ProductionPriority
|
||||
from .inventory_client import InventoryClient
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductionService:
|
||||
"""Service for production management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.production_repo = ProductionRepository(db)
|
||||
self.consumption_repo = ProductionIngredientConsumptionRepository(db)
|
||||
self.schedule_repo = ProductionScheduleRepository(db)
|
||||
self.recipe_repo = RecipeRepository(db)
|
||||
self.inventory_client = InventoryClient()
|
||||
|
||||
async def create_production_batch(
|
||||
self,
|
||||
batch_data: Dict[str, Any],
|
||||
created_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new production batch"""
|
||||
try:
|
||||
# Validate recipe exists and is active
|
||||
recipe = self.recipe_repo.get_by_id(batch_data["recipe_id"])
|
||||
if not recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
if recipe.tenant_id != batch_data["tenant_id"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe does not belong to this tenant"
|
||||
}
|
||||
|
||||
# Check recipe feasibility if needed
|
||||
if batch_data.get("check_feasibility", True):
|
||||
from .recipe_service import RecipeService
|
||||
recipe_service = RecipeService(self.db)
|
||||
feasibility = await recipe_service.check_recipe_feasibility(
|
||||
recipe.id,
|
||||
batch_data.get("batch_size_multiplier", 1.0)
|
||||
)
|
||||
|
||||
if feasibility["success"] and not feasibility["data"]["feasible"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Insufficient ingredients available for production",
|
||||
"details": feasibility["data"]
|
||||
}
|
||||
|
||||
# Generate batch number if not provided
|
||||
if not batch_data.get("batch_number"):
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
count = self.production_repo.count_by_tenant(batch_data["tenant_id"])
|
||||
batch_data["batch_number"] = f"BATCH-{date_str}-{count + 1:04d}"
|
||||
|
||||
# Set defaults
|
||||
batch_data["created_by"] = created_by
|
||||
batch_data["status"] = ProductionStatus.PLANNED
|
||||
|
||||
batch = self.production_repo.create(batch_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production batch: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def start_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
ingredient_consumptions: List[Dict[str, Any]],
|
||||
staff_member: UUID,
|
||||
notes: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Start production batch and record ingredient consumptions"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
if batch.status != ProductionStatus.PLANNED:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cannot start batch in {batch.status.value} status"
|
||||
}
|
||||
|
||||
# Reserve ingredients in inventory
|
||||
reservations = []
|
||||
for consumption in ingredient_consumptions:
|
||||
reservations.append({
|
||||
"ingredient_id": str(consumption["ingredient_id"]),
|
||||
"quantity": consumption["actual_quantity"],
|
||||
"unit": consumption["unit"].value if hasattr(consumption["unit"], "value") else consumption["unit"],
|
||||
"reference": str(batch_id)
|
||||
})
|
||||
|
||||
reserve_result = await self.inventory_client.reserve_ingredients(
|
||||
batch.tenant_id,
|
||||
reservations
|
||||
)
|
||||
|
||||
if not reserve_result["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to reserve ingredients: {reserve_result['error']}"
|
||||
}
|
||||
|
||||
# Update batch status
|
||||
self.production_repo.update_batch_status(
|
||||
batch_id,
|
||||
ProductionStatus.IN_PROGRESS,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
# Record ingredient consumptions
|
||||
for consumption_data in ingredient_consumptions:
|
||||
consumption_data["tenant_id"] = batch.tenant_id
|
||||
consumption_data["production_batch_id"] = batch_id
|
||||
consumption_data["staff_member"] = staff_member
|
||||
consumption_data["consumption_time"] = datetime.utcnow()
|
||||
|
||||
# Calculate variance
|
||||
planned = consumption_data["planned_quantity"]
|
||||
actual = consumption_data["actual_quantity"]
|
||||
consumption_data["variance_quantity"] = actual - planned
|
||||
if planned > 0:
|
||||
consumption_data["variance_percentage"] = ((actual - planned) / planned) * 100
|
||||
|
||||
self.consumption_repo.create(consumption_data)
|
||||
|
||||
# Get updated batch
|
||||
updated_batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def complete_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
completion_data: Dict[str, Any],
|
||||
completed_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Complete production batch and add finished products to inventory"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
if batch.status != ProductionStatus.IN_PROGRESS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cannot complete batch in {batch.status.value} status"
|
||||
}
|
||||
|
||||
# Calculate yield percentage
|
||||
actual_quantity = completion_data["actual_quantity"]
|
||||
yield_percentage = (actual_quantity / batch.planned_quantity) * 100
|
||||
|
||||
# Calculate efficiency percentage
|
||||
efficiency_percentage = None
|
||||
if batch.actual_start_time and batch.planned_start_time and batch.planned_end_time:
|
||||
planned_duration = (batch.planned_end_time - batch.planned_start_time).total_seconds()
|
||||
actual_duration = (datetime.utcnow() - batch.actual_start_time).total_seconds()
|
||||
if actual_duration > 0:
|
||||
efficiency_percentage = (planned_duration / actual_duration) * 100
|
||||
|
||||
# Update batch with completion data
|
||||
update_data = {
|
||||
"actual_quantity": actual_quantity,
|
||||
"yield_percentage": yield_percentage,
|
||||
"efficiency_percentage": efficiency_percentage,
|
||||
"actual_end_time": datetime.utcnow(),
|
||||
"completed_by": completed_by,
|
||||
"status": ProductionStatus.COMPLETED,
|
||||
**{k: v for k, v in completion_data.items() if k != "actual_quantity"}
|
||||
}
|
||||
|
||||
updated_batch = self.production_repo.update(batch_id, update_data)
|
||||
|
||||
# Add finished products to inventory
|
||||
recipe = self.recipe_repo.get_by_id(batch.recipe_id)
|
||||
if recipe:
|
||||
product_data = {
|
||||
"ingredient_id": str(recipe.finished_product_id),
|
||||
"quantity": actual_quantity,
|
||||
"batch_number": batch.batch_number,
|
||||
"production_date": batch.production_date.isoformat(),
|
||||
"reference_number": str(batch_id),
|
||||
"movement_type": "production",
|
||||
"notes": f"Production batch {batch.batch_number}"
|
||||
}
|
||||
|
||||
inventory_result = await self.inventory_client.add_finished_product_to_inventory(
|
||||
batch.tenant_id,
|
||||
product_data
|
||||
)
|
||||
|
||||
if not inventory_result["success"]:
|
||||
logger.warning(f"Failed to add finished product to inventory: {inventory_result['error']}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error completing production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_production_batch_with_consumptions(self, batch_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get production batch with all consumption records"""
|
||||
batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
if not batch:
|
||||
return None
|
||||
|
||||
batch_dict = batch.to_dict()
|
||||
batch_dict["ingredient_consumptions"] = [
|
||||
cons.to_dict() for cons in batch.ingredient_consumptions
|
||||
]
|
||||
|
||||
return batch_dict
|
||||
|
||||
def search_production_batches(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
recipe_id: Optional[UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search production batches with filters"""
|
||||
production_status = ProductionStatus(status) if status else None
|
||||
production_priority = ProductionPriority(priority) if priority else None
|
||||
|
||||
batches = self.production_repo.search_batches(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=production_status,
|
||||
priority=production_priority,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
recipe_id=recipe_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return [batch.to_dict() for batch in batches]
|
||||
|
||||
def get_active_production_batches(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Get all active production batches"""
|
||||
batches = self.production_repo.get_active_batches(tenant_id)
|
||||
return [batch.to_dict() for batch in batches]
|
||||
|
||||
def get_production_statistics(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get production statistics for dashboard"""
|
||||
return self.production_repo.get_production_statistics(tenant_id, start_date, end_date)
|
||||
|
||||
async def update_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
update_data: Dict[str, Any],
|
||||
updated_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Update production batch"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
# Add audit info
|
||||
update_data["updated_by"] = updated_by
|
||||
|
||||
updated_batch = self.production_repo.update(batch_id, update_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# Production Schedule methods
|
||||
def create_production_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new production schedule"""
|
||||
try:
|
||||
schedule = self.schedule_repo.create(schedule_data)
|
||||
return {
|
||||
"success": True,
|
||||
"data": schedule.to_dict()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production schedule: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_production_schedule(self, schedule_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get production schedule by ID"""
|
||||
schedule = self.schedule_repo.get_by_id(schedule_id)
|
||||
return schedule.to_dict() if schedule else None
|
||||
|
||||
def get_production_schedule_by_date(self, tenant_id: UUID, schedule_date: date) -> Optional[Dict[str, Any]]:
|
||||
"""Get production schedule for specific date"""
|
||||
schedule = self.schedule_repo.get_by_date(tenant_id, schedule_date)
|
||||
return schedule.to_dict() if schedule else None
|
||||
|
||||
def get_published_schedules(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get published schedules within date range"""
|
||||
schedules = self.schedule_repo.get_published_schedules(tenant_id, start_date, end_date)
|
||||
return [schedule.to_dict() for schedule in schedules]
|
||||
|
||||
def get_production_schedules_range(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get all schedules within date range"""
|
||||
if not start_date:
|
||||
start_date = date.today()
|
||||
if not end_date:
|
||||
from datetime import timedelta
|
||||
end_date = start_date + timedelta(days=7)
|
||||
|
||||
schedules = self.schedule_repo.get_published_schedules(tenant_id, start_date, end_date)
|
||||
return [schedule.to_dict() for schedule in schedules]
|
||||
@@ -7,186 +7,30 @@ import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..repositories.recipe_repository import RecipeRepository, RecipeIngredientRepository
|
||||
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
|
||||
from .inventory_client import InventoryClient
|
||||
from ..core.config import settings
|
||||
from ..repositories.recipe_repository import RecipeRepository
|
||||
from ..schemas.recipes import RecipeCreate, RecipeUpdate
|
||||
|
||||
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"""
|
||||
"""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:
|
||||
# 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()
|
||||
}
|
||||
|
||||
return await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
|
||||
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:
|
||||
logger.error(f"Error getting recipe {recipe_id}: {e}")
|
||||
return None
|
||||
|
||||
recipe_dict = recipe.to_dict()
|
||||
recipe_dict["ingredients"] = [ing.to_dict() for ing in recipe.ingredients]
|
||||
|
||||
return recipe_dict
|
||||
|
||||
def search_recipes(
|
||||
|
||||
async def search_recipes(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
@@ -199,176 +43,222 @@ class RecipeService:
|
||||
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]:
|
||||
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"""
|
||||
return self.recipe_repo.get_recipe_statistics(tenant_id)
|
||||
|
||||
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 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:
|
||||
# Add metadata
|
||||
recipe_data["created_by"] = created_by
|
||||
recipe_data["created_at"] = datetime.utcnow()
|
||||
recipe_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
# Use the shared repository's create method
|
||||
recipe_create = RecipeCreate(**recipe_data)
|
||||
recipe = await self.recipe_repo.create(recipe_create)
|
||||
|
||||
# Get the created recipe with ingredients (if the repository supports it)
|
||||
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"
|
||||
}
|
||||
|
||||
# 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 = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
|
||||
recipe = await self.recipe_repo.get_recipe_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"]
|
||||
|
||||
|
||||
# Simplified feasibility check - can be enhanced later with inventory service integration
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"recipe_id": str(recipe_id),
|
||||
"recipe_name": recipe.name,
|
||||
"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", [])
|
||||
"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,
|
||||
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:
|
||||
# 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"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": new_recipe.to_dict()
|
||||
}
|
||||
|
||||
|
||||
# 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}")
|
||||
self.db.rollback()
|
||||
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 is complete and valid
|
||||
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
|
||||
# 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.ingredients:
|
||||
|
||||
if not recipe.get("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
|
||||
})
|
||||
|
||||
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": updated_recipe.to_dict()
|
||||
"data": result
|
||||
}
|
||||
|
||||
|
||||
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}")
|
||||
}
|
||||
Reference in New Issue
Block a user