Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View File

@@ -0,0 +1 @@
# services/recipes/app/__init__.py

View File

@@ -0,0 +1 @@
# services/recipes/app/api/__init__.py

View File

@@ -0,0 +1,117 @@
# services/recipes/app/api/ingredients.py
"""
API endpoints for ingredient-related operations (bridge to inventory service)
"""
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from typing import List, Optional
from uuid import UUID
import logging
from ..services.inventory_client import InventoryClient
logger = logging.getLogger(__name__)
router = APIRouter()
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
"""Extract tenant ID from header"""
try:
return UUID(x_tenant_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
@router.get("/search")
async def search_ingredients(
tenant_id: UUID = Depends(get_tenant_id),
search_term: Optional[str] = Query(None),
product_type: Optional[str] = Query(None),
category: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0)
):
"""Search ingredients from inventory service"""
try:
inventory_client = InventoryClient()
# This would call the inventory service search endpoint
# For now, return a placeholder response
return {
"ingredients": [],
"total": 0,
"message": "Integration with inventory service needed"
}
except Exception as e:
logger.error(f"Error searching ingredients: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{ingredient_id}")
async def get_ingredient(
ingredient_id: UUID,
tenant_id: UUID = Depends(get_tenant_id)
):
"""Get ingredient details from inventory service"""
try:
inventory_client = InventoryClient()
ingredient = await inventory_client.get_ingredient_by_id(tenant_id, ingredient_id)
if not ingredient:
raise HTTPException(status_code=404, detail="Ingredient not found")
return ingredient
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting ingredient {ingredient_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{ingredient_id}/stock")
async def get_ingredient_stock(
ingredient_id: UUID,
tenant_id: UUID = Depends(get_tenant_id)
):
"""Get ingredient stock level from inventory service"""
try:
inventory_client = InventoryClient()
stock = await inventory_client.get_ingredient_stock_level(tenant_id, ingredient_id)
if not stock:
raise HTTPException(status_code=404, detail="Stock information not found")
return stock
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting stock for ingredient {ingredient_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/check-availability")
async def check_ingredients_availability(
required_ingredients: List[dict],
tenant_id: UUID = Depends(get_tenant_id)
):
"""Check if required ingredients are available for production"""
try:
inventory_client = InventoryClient()
result = await inventory_client.check_ingredient_availability(
tenant_id,
required_ingredients
)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return result["data"]
except HTTPException:
raise
except Exception as e:
logger.error(f"Error checking ingredient availability: {e}")
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1,427 @@
# services/recipes/app/api/production.py
"""
API endpoints for production management
"""
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from uuid import UUID
from datetime import date, datetime
import logging
from ..core.database import get_db
from ..services.production_service import ProductionService
from ..schemas.production import (
ProductionBatchCreate,
ProductionBatchUpdate,
ProductionBatchResponse,
ProductionBatchSearchRequest,
ProductionScheduleCreate,
ProductionScheduleUpdate,
ProductionScheduleResponse,
ProductionStatisticsResponse,
StartProductionRequest,
CompleteProductionRequest
)
logger = logging.getLogger(__name__)
router = APIRouter()
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
"""Extract tenant ID from header"""
try:
return UUID(x_tenant_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
def get_user_id(x_user_id: str = Header(...)) -> UUID:
"""Extract user ID from header"""
try:
return UUID(x_user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID format")
# Production Batch Endpoints
@router.post("/batches", response_model=ProductionBatchResponse)
async def create_production_batch(
batch_data: ProductionBatchCreate,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Create a new production batch"""
try:
production_service = ProductionService(db)
batch_dict = batch_data.dict()
batch_dict["tenant_id"] = tenant_id
result = await production_service.create_production_batch(batch_dict, user_id)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return ProductionBatchResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating production batch: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/batches/{batch_id}", response_model=ProductionBatchResponse)
async def get_production_batch(
batch_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get production batch by ID with consumptions"""
try:
production_service = ProductionService(db)
batch = production_service.get_production_batch_with_consumptions(batch_id)
if not batch:
raise HTTPException(status_code=404, detail="Production batch not found")
# Verify tenant ownership
if batch["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
return ProductionBatchResponse(**batch)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting production batch {batch_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.put("/batches/{batch_id}", response_model=ProductionBatchResponse)
async def update_production_batch(
batch_id: UUID,
batch_data: ProductionBatchUpdate,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Update an existing production batch"""
try:
production_service = ProductionService(db)
# Check if batch exists and belongs to tenant
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
if not existing_batch:
raise HTTPException(status_code=404, detail="Production batch not found")
if existing_batch["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
batch_dict = batch_data.dict(exclude_unset=True)
result = await production_service.update_production_batch(batch_id, batch_dict, user_id)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return ProductionBatchResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating production batch {batch_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/batches/{batch_id}")
async def delete_production_batch(
batch_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Delete a production batch"""
try:
production_service = ProductionService(db)
# Check if batch exists and belongs to tenant
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
if not existing_batch:
raise HTTPException(status_code=404, detail="Production batch not found")
if existing_batch["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
success = production_service.production_repo.delete(batch_id)
if not success:
raise HTTPException(status_code=404, detail="Production batch not found")
return {"message": "Production batch deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting production batch {batch_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/batches", response_model=List[ProductionBatchResponse])
async def search_production_batches(
tenant_id: UUID = Depends(get_tenant_id),
search_term: Optional[str] = Query(None),
status: Optional[str] = Query(None),
priority: Optional[str] = Query(None),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
recipe_id: Optional[UUID] = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db)
):
"""Search production batches with filters"""
try:
production_service = ProductionService(db)
batches = production_service.search_production_batches(
tenant_id=tenant_id,
search_term=search_term,
status=status,
priority=priority,
start_date=start_date,
end_date=end_date,
recipe_id=recipe_id,
limit=limit,
offset=offset
)
return [ProductionBatchResponse(**batch) for batch in batches]
except Exception as e:
logger.error(f"Error searching production batches: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/batches/{batch_id}/start", response_model=ProductionBatchResponse)
async def start_production_batch(
batch_id: UUID,
start_data: StartProductionRequest,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Start production batch and record ingredient consumptions"""
try:
production_service = ProductionService(db)
# Check if batch exists and belongs to tenant
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
if not existing_batch:
raise HTTPException(status_code=404, detail="Production batch not found")
if existing_batch["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
consumptions_list = [cons.dict() for cons in start_data.ingredient_consumptions]
result = await production_service.start_production_batch(
batch_id,
consumptions_list,
start_data.staff_member or user_id,
start_data.production_notes
)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return ProductionBatchResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error starting production batch {batch_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/batches/{batch_id}/complete", response_model=ProductionBatchResponse)
async def complete_production_batch(
batch_id: UUID,
complete_data: CompleteProductionRequest,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Complete production batch and add finished products to inventory"""
try:
production_service = ProductionService(db)
# Check if batch exists and belongs to tenant
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
if not existing_batch:
raise HTTPException(status_code=404, detail="Production batch not found")
if existing_batch["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
completion_data = complete_data.dict()
result = await production_service.complete_production_batch(
batch_id,
completion_data,
complete_data.staff_member or user_id
)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return ProductionBatchResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error completing production batch {batch_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/batches/active/list", response_model=List[ProductionBatchResponse])
async def get_active_production_batches(
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get all active production batches"""
try:
production_service = ProductionService(db)
batches = production_service.get_active_production_batches(tenant_id)
return [ProductionBatchResponse(**batch) for batch in batches]
except Exception as e:
logger.error(f"Error getting active production batches: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/statistics/dashboard", response_model=ProductionStatisticsResponse)
async def get_production_statistics(
tenant_id: UUID = Depends(get_tenant_id),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
db: Session = Depends(get_db)
):
"""Get production statistics for dashboard"""
try:
production_service = ProductionService(db)
stats = production_service.get_production_statistics(tenant_id, start_date, end_date)
return ProductionStatisticsResponse(**stats)
except Exception as e:
logger.error(f"Error getting production statistics: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
# Production Schedule Endpoints
@router.post("/schedules", response_model=ProductionScheduleResponse)
async def create_production_schedule(
schedule_data: ProductionScheduleCreate,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Create a new production schedule"""
try:
production_service = ProductionService(db)
schedule_dict = schedule_data.dict()
schedule_dict["tenant_id"] = tenant_id
schedule_dict["created_by"] = user_id
result = production_service.create_production_schedule(schedule_dict)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return ProductionScheduleResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating production schedule: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/schedules/{schedule_id}", response_model=ProductionScheduleResponse)
async def get_production_schedule(
schedule_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get production schedule by ID"""
try:
production_service = ProductionService(db)
schedule = production_service.get_production_schedule(schedule_id)
if not schedule:
raise HTTPException(status_code=404, detail="Production schedule not found")
# Verify tenant ownership
if schedule["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
return ProductionScheduleResponse(**schedule)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting production schedule {schedule_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/schedules/date/{schedule_date}", response_model=ProductionScheduleResponse)
async def get_production_schedule_by_date(
schedule_date: date,
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get production schedule for specific date"""
try:
production_service = ProductionService(db)
schedule = production_service.get_production_schedule_by_date(tenant_id, schedule_date)
if not schedule:
raise HTTPException(status_code=404, detail="Production schedule not found for this date")
return ProductionScheduleResponse(**schedule)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting production schedule for {schedule_date}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/schedules", response_model=List[ProductionScheduleResponse])
async def get_production_schedules(
tenant_id: UUID = Depends(get_tenant_id),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
published_only: bool = Query(False),
db: Session = Depends(get_db)
):
"""Get production schedules within date range"""
try:
production_service = ProductionService(db)
if published_only:
schedules = production_service.get_published_schedules(tenant_id, start_date, end_date)
else:
schedules = production_service.get_production_schedules_range(tenant_id, start_date, end_date)
return [ProductionScheduleResponse(**schedule) for schedule in schedules]
except Exception as e:
logger.error(f"Error getting production schedules: {e}")
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1,359 @@
# services/recipes/app/api/recipes.py
"""
API endpoints for recipe management
"""
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from uuid import UUID
import logging
from ..core.database import get_db
from ..services.recipe_service import RecipeService
from ..schemas.recipes import (
RecipeCreate,
RecipeUpdate,
RecipeResponse,
RecipeSearchRequest,
RecipeDuplicateRequest,
RecipeFeasibilityResponse,
RecipeStatisticsResponse
)
logger = logging.getLogger(__name__)
router = APIRouter()
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
"""Extract tenant ID from header"""
try:
return UUID(x_tenant_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
def get_user_id(x_user_id: str = Header(...)) -> UUID:
"""Extract user ID from header"""
try:
return UUID(x_user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID format")
@router.post("/", response_model=RecipeResponse)
async def create_recipe(
recipe_data: RecipeCreate,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Create a new recipe"""
try:
recipe_service = RecipeService(db)
# Convert Pydantic model to dict
recipe_dict = recipe_data.dict(exclude={"ingredients"})
recipe_dict["tenant_id"] = tenant_id
ingredients_list = [ing.dict() for ing in recipe_data.ingredients]
result = await recipe_service.create_recipe(
recipe_dict,
ingredients_list,
user_id
)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return RecipeResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating recipe: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{recipe_id}", response_model=RecipeResponse)
async def get_recipe(
recipe_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get recipe by ID with ingredients"""
try:
recipe_service = RecipeService(db)
recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
# Verify tenant ownership
if recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
return RecipeResponse(**recipe)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting recipe {recipe_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.put("/{recipe_id}", response_model=RecipeResponse)
async def update_recipe(
recipe_id: UUID,
recipe_data: RecipeUpdate,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Update an existing recipe"""
try:
recipe_service = RecipeService(db)
# Check if recipe exists and belongs to tenant
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
if existing_recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
# Convert Pydantic model to dict
recipe_dict = recipe_data.dict(exclude={"ingredients"}, exclude_unset=True)
ingredients_list = None
if recipe_data.ingredients is not None:
ingredients_list = [ing.dict() for ing in recipe_data.ingredients]
result = await recipe_service.update_recipe(
recipe_id,
recipe_dict,
ingredients_list,
user_id
)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return RecipeResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating recipe {recipe_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/{recipe_id}")
async def delete_recipe(
recipe_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Delete a recipe"""
try:
recipe_service = RecipeService(db)
# Check if recipe exists and belongs to tenant
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
if existing_recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
# Use repository to delete
success = recipe_service.recipe_repo.delete(recipe_id)
if not success:
raise HTTPException(status_code=404, detail="Recipe not found")
return {"message": "Recipe deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting recipe {recipe_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/", response_model=List[RecipeResponse])
async def search_recipes(
tenant_id: UUID = Depends(get_tenant_id),
search_term: Optional[str] = Query(None),
status: Optional[str] = Query(None),
category: Optional[str] = Query(None),
is_seasonal: Optional[bool] = Query(None),
is_signature: Optional[bool] = Query(None),
difficulty_level: Optional[int] = Query(None, ge=1, le=5),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db)
):
"""Search recipes with filters"""
try:
recipe_service = RecipeService(db)
recipes = recipe_service.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
)
return [RecipeResponse(**recipe) for recipe in recipes]
except Exception as e:
logger.error(f"Error searching recipes: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse)
async def duplicate_recipe(
recipe_id: UUID,
duplicate_data: RecipeDuplicateRequest,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Create a duplicate of an existing recipe"""
try:
recipe_service = RecipeService(db)
# Check if original recipe exists and belongs to tenant
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
if existing_recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
result = await recipe_service.duplicate_recipe(
recipe_id,
duplicate_data.new_name,
user_id
)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return RecipeResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error duplicating recipe {recipe_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/{recipe_id}/activate", response_model=RecipeResponse)
async def activate_recipe(
recipe_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
):
"""Activate a recipe for production"""
try:
recipe_service = RecipeService(db)
# Check if recipe exists and belongs to tenant
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
if existing_recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
result = await recipe_service.activate_recipe(recipe_id, user_id)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return RecipeResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error activating recipe {recipe_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{recipe_id}/feasibility", response_model=RecipeFeasibilityResponse)
async def check_recipe_feasibility(
recipe_id: UUID,
batch_multiplier: float = Query(1.0, gt=0),
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Check if recipe can be produced with current inventory"""
try:
recipe_service = RecipeService(db)
# Check if recipe exists and belongs to tenant
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
if existing_recipe["tenant_id"] != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied")
result = await recipe_service.check_recipe_feasibility(recipe_id, batch_multiplier)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return RecipeFeasibilityResponse(**result["data"])
except HTTPException:
raise
except Exception as e:
logger.error(f"Error checking recipe feasibility {recipe_id}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/statistics/dashboard", response_model=RecipeStatisticsResponse)
async def get_recipe_statistics(
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get recipe statistics for dashboard"""
try:
recipe_service = RecipeService(db)
stats = recipe_service.get_recipe_statistics(tenant_id)
return RecipeStatisticsResponse(**stats)
except Exception as e:
logger.error(f"Error getting recipe statistics: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/categories/list")
async def get_recipe_categories(
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get list of recipe categories used by tenant"""
try:
recipe_service = RecipeService(db)
# Get categories from existing recipes
recipes = recipe_service.search_recipes(tenant_id, limit=1000)
categories = list(set(recipe["category"] for recipe in recipes if recipe["category"]))
categories.sort()
return {"categories": categories}
except Exception as e:
logger.error(f"Error getting recipe categories: {e}")
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1 @@
# services/recipes/app/core/__init__.py

View File

@@ -0,0 +1,82 @@
# services/recipes/app/core/config.py
"""
Configuration management for Recipe Service
"""
import os
from typing import Optional
class Settings:
"""Recipe service configuration settings"""
# Service identification
SERVICE_NAME: str = "recipes"
SERVICE_VERSION: str = "1.0.0"
# API settings
API_V1_PREFIX: str = "/api/v1"
# Database
DATABASE_URL: str = os.getenv(
"RECIPES_DATABASE_URL",
"postgresql://recipes_user:recipes_pass@localhost:5432/recipes_db"
)
# Redis (if needed for caching)
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
# External service URLs
INVENTORY_SERVICE_URL: str = os.getenv(
"INVENTORY_SERVICE_URL",
"http://inventory:8000"
)
SALES_SERVICE_URL: str = os.getenv(
"SALES_SERVICE_URL",
"http://sales:8000"
)
# Authentication
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here")
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
# Logging
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# Production configuration
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
# CORS settings
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",")
# Recipe-specific settings
MAX_RECIPE_INGREDIENTS: int = int(os.getenv("MAX_RECIPE_INGREDIENTS", "50"))
MAX_BATCH_SIZE_MULTIPLIER: float = float(os.getenv("MAX_BATCH_SIZE_MULTIPLIER", "10.0"))
DEFAULT_RECIPE_VERSION: str = "1.0"
# Production settings
MAX_PRODUCTION_BATCHES_PER_DAY: int = int(os.getenv("MAX_PRODUCTION_BATCHES_PER_DAY", "100"))
PRODUCTION_SCHEDULE_DAYS_AHEAD: int = int(os.getenv("PRODUCTION_SCHEDULE_DAYS_AHEAD", "7"))
# Cost calculation settings
OVERHEAD_PERCENTAGE: float = float(os.getenv("OVERHEAD_PERCENTAGE", "15.0")) # Default 15% overhead
LABOR_COST_PER_HOUR: float = float(os.getenv("LABOR_COST_PER_HOUR", "25.0")) # Default €25/hour
# Quality control
MIN_QUALITY_SCORE: float = float(os.getenv("MIN_QUALITY_SCORE", "6.0")) # Minimum acceptable quality score
MAX_DEFECT_RATE: float = float(os.getenv("MAX_DEFECT_RATE", "5.0")) # Maximum 5% defect rate
# Messaging/Events (if using message queues)
RABBITMQ_URL: Optional[str] = os.getenv("RABBITMQ_URL")
KAFKA_BOOTSTRAP_SERVERS: Optional[str] = os.getenv("KAFKA_BOOTSTRAP_SERVERS")
# Health check settings
HEALTH_CHECK_TIMEOUT: int = int(os.getenv("HEALTH_CHECK_TIMEOUT", "30"))
class Config:
case_sensitive = True
# Global settings instance
settings = Settings()

View File

@@ -0,0 +1,77 @@
# services/recipes/app/core/database.py
"""
Database configuration and session management for Recipe Service
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import StaticPool
from contextlib import contextmanager
from typing import Generator
from .config import settings
# Create database engine
engine = create_engine(
settings.DATABASE_URL,
poolclass=StaticPool,
pool_pre_ping=True,
pool_recycle=300,
echo=settings.DEBUG,
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db() -> Generator[Session, None, None]:
"""
Dependency to get database session
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
@contextmanager
def get_db_context() -> Generator[Session, None, None]:
"""
Context manager for database session
"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
class DatabaseManager:
"""Database management utilities"""
@staticmethod
def create_all_tables():
"""Create all database tables"""
from shared.database.base import Base
Base.metadata.create_all(bind=engine)
@staticmethod
def drop_all_tables():
"""Drop all database tables (for testing)"""
from shared.database.base import Base
Base.metadata.drop_all(bind=engine)
@staticmethod
def get_session() -> Session:
"""Get a new database session"""
return SessionLocal()
# Database manager instance
db_manager = DatabaseManager()

View File

@@ -0,0 +1,161 @@
# services/recipes/app/main.py
"""
Recipe Service - FastAPI application
Handles recipe management, production planning, and inventory consumption tracking
"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
import time
import logging
from contextlib import asynccontextmanager
from .core.config import settings
from .core.database import db_manager
from .api import recipes, production, ingredients
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events"""
# Startup
logger.info(f"Starting {settings.SERVICE_NAME} service v{settings.SERVICE_VERSION}")
# Create database tables
try:
db_manager.create_all_tables()
logger.info("Database tables created successfully")
except Exception as e:
logger.error(f"Failed to create database tables: {e}")
yield
# Shutdown
logger.info(f"Shutting down {settings.SERVICE_NAME} service")
# Create FastAPI application
app = FastAPI(
title="Recipe Management Service",
description="Comprehensive recipe management, production planning, and inventory consumption tracking for bakery operations",
version=settings.SERVICE_VERSION,
lifespan=lifespan,
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
)
# Add middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Request timing middleware
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
"""Add processing time header to responses"""
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Global exception handler"""
logger.error(f"Global exception on {request.url}: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"detail": "Internal server error",
"error": str(exc) if settings.DEBUG else "An unexpected error occurred"
}
)
# Health check endpoint
@app.get("/health")
async def health_check():
"""Health check endpoint"""
try:
# Test database connection
with db_manager.get_session() as db:
db.execute("SELECT 1")
return {
"status": "healthy",
"service": settings.SERVICE_NAME,
"version": settings.SERVICE_VERSION,
"environment": settings.ENVIRONMENT
}
except Exception as e:
logger.error(f"Health check failed: {e}")
return JSONResponse(
status_code=503,
content={
"status": "unhealthy",
"service": settings.SERVICE_NAME,
"version": settings.SERVICE_VERSION,
"error": str(e)
}
)
# Include API routers
app.include_router(
recipes.router,
prefix=f"{settings.API_V1_PREFIX}/recipes",
tags=["recipes"]
)
app.include_router(
production.router,
prefix=f"{settings.API_V1_PREFIX}/production",
tags=["production"]
)
app.include_router(
ingredients.router,
prefix=f"{settings.API_V1_PREFIX}/ingredients",
tags=["ingredients"]
)
@app.get("/")
async def root():
"""Root endpoint"""
return {
"service": settings.SERVICE_NAME,
"version": settings.SERVICE_VERSION,
"status": "running",
"docs_url": "/docs" if settings.DEBUG else None
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG,
log_level=settings.LOG_LEVEL.lower()
)

View File

@@ -0,0 +1,25 @@
# services/recipes/app/models/__init__.py
from .recipes import (
Recipe,
RecipeIngredient,
ProductionBatch,
ProductionIngredientConsumption,
ProductionSchedule,
RecipeStatus,
ProductionStatus,
MeasurementUnit,
ProductionPriority
)
__all__ = [
"Recipe",
"RecipeIngredient",
"ProductionBatch",
"ProductionIngredientConsumption",
"ProductionSchedule",
"RecipeStatus",
"ProductionStatus",
"MeasurementUnit",
"ProductionPriority"
]

View File

@@ -0,0 +1,535 @@
# services/recipes/app/models/recipes.py
"""
Recipe and Production Management models for Recipe Service
Comprehensive recipe management, production tracking, and inventory consumption
"""
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric, ForeignKey, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
import uuid
import enum
from datetime import datetime, timezone
from typing import Dict, Any, Optional, List
from shared.database.base import Base
class RecipeStatus(enum.Enum):
"""Recipe lifecycle status"""
DRAFT = "draft"
ACTIVE = "active"
TESTING = "testing"
ARCHIVED = "archived"
DISCONTINUED = "discontinued"
class ProductionStatus(enum.Enum):
"""Production batch status"""
PLANNED = "planned"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class MeasurementUnit(enum.Enum):
"""Units for recipe measurements"""
GRAMS = "g"
KILOGRAMS = "kg"
MILLILITERS = "ml"
LITERS = "l"
CUPS = "cups"
TABLESPOONS = "tbsp"
TEASPOONS = "tsp"
UNITS = "units"
PIECES = "pieces"
PERCENTAGE = "%"
class ProductionPriority(enum.Enum):
"""Production batch priority levels"""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
class Recipe(Base):
"""Master recipe definitions"""
__tablename__ = "recipes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Recipe identification
name = Column(String(255), nullable=False, index=True)
recipe_code = Column(String(100), nullable=True, index=True)
version = Column(String(20), nullable=False, default="1.0")
# Product association
finished_product_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Links to inventory ingredient with product_type=finished_product
# Recipe details
description = Column(Text, nullable=True)
category = Column(String(100), nullable=True, index=True) # bread, pastries, cakes, etc.
cuisine_type = Column(String(100), nullable=True)
difficulty_level = Column(Integer, nullable=False, default=1) # 1-5 scale
# Production metrics
yield_quantity = Column(Float, nullable=False) # How many units this recipe produces
yield_unit = Column(SQLEnum(MeasurementUnit), nullable=False)
prep_time_minutes = Column(Integer, nullable=True)
cook_time_minutes = Column(Integer, nullable=True)
total_time_minutes = Column(Integer, nullable=True)
rest_time_minutes = Column(Integer, nullable=True) # Rising time, cooling time, etc.
# Cost and pricing
estimated_cost_per_unit = Column(Numeric(10, 2), nullable=True)
last_calculated_cost = Column(Numeric(10, 2), nullable=True)
cost_calculation_date = Column(DateTime(timezone=True), nullable=True)
target_margin_percentage = Column(Float, nullable=True)
suggested_selling_price = Column(Numeric(10, 2), nullable=True)
# Instructions and notes
instructions = Column(JSONB, nullable=True) # Structured step-by-step instructions
preparation_notes = Column(Text, nullable=True)
storage_instructions = Column(Text, nullable=True)
quality_standards = Column(Text, nullable=True)
# Recipe metadata
serves_count = Column(Integer, nullable=True) # How many people/portions
nutritional_info = Column(JSONB, nullable=True) # Calories, protein, etc.
allergen_info = Column(JSONB, nullable=True) # List of allergens
dietary_tags = Column(JSONB, nullable=True) # vegan, gluten-free, etc.
# Production settings
batch_size_multiplier = Column(Float, nullable=False, default=1.0) # Standard batch multiplier
minimum_batch_size = Column(Float, nullable=True)
maximum_batch_size = Column(Float, nullable=True)
optimal_production_temperature = Column(Float, nullable=True) # Celsius
optimal_humidity = Column(Float, nullable=True) # Percentage
# Quality control
quality_check_points = Column(JSONB, nullable=True) # Key checkpoints during production
common_issues = Column(JSONB, nullable=True) # Known issues and solutions
# Status and lifecycle
status = Column(SQLEnum(RecipeStatus), nullable=False, default=RecipeStatus.DRAFT, index=True)
is_seasonal = Column(Boolean, default=False)
season_start_month = Column(Integer, nullable=True) # 1-12
season_end_month = Column(Integer, nullable=True) # 1-12
is_signature_item = Column(Boolean, default=False)
# Audit fields
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
created_by = Column(UUID(as_uuid=True), nullable=True)
updated_by = Column(UUID(as_uuid=True), nullable=True)
# Relationships
ingredients = relationship("RecipeIngredient", back_populates="recipe", cascade="all, delete-orphan")
production_batches = relationship("ProductionBatch", back_populates="recipe", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_recipes_tenant_name', 'tenant_id', 'name'),
Index('idx_recipes_tenant_product', 'tenant_id', 'finished_product_id'),
Index('idx_recipes_status', 'tenant_id', 'status'),
Index('idx_recipes_category', 'tenant_id', 'category', 'status'),
Index('idx_recipes_seasonal', 'tenant_id', 'is_seasonal', 'season_start_month', 'season_end_month'),
Index('idx_recipes_signature', 'tenant_id', 'is_signature_item', 'status'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'name': self.name,
'recipe_code': self.recipe_code,
'version': self.version,
'finished_product_id': str(self.finished_product_id),
'description': self.description,
'category': self.category,
'cuisine_type': self.cuisine_type,
'difficulty_level': self.difficulty_level,
'yield_quantity': self.yield_quantity,
'yield_unit': self.yield_unit.value if self.yield_unit else None,
'prep_time_minutes': self.prep_time_minutes,
'cook_time_minutes': self.cook_time_minutes,
'total_time_minutes': self.total_time_minutes,
'rest_time_minutes': self.rest_time_minutes,
'estimated_cost_per_unit': float(self.estimated_cost_per_unit) if self.estimated_cost_per_unit else None,
'last_calculated_cost': float(self.last_calculated_cost) if self.last_calculated_cost else None,
'cost_calculation_date': self.cost_calculation_date.isoformat() if self.cost_calculation_date else None,
'target_margin_percentage': self.target_margin_percentage,
'suggested_selling_price': float(self.suggested_selling_price) if self.suggested_selling_price else None,
'instructions': self.instructions,
'preparation_notes': self.preparation_notes,
'storage_instructions': self.storage_instructions,
'quality_standards': self.quality_standards,
'serves_count': self.serves_count,
'nutritional_info': self.nutritional_info,
'allergen_info': self.allergen_info,
'dietary_tags': self.dietary_tags,
'batch_size_multiplier': self.batch_size_multiplier,
'minimum_batch_size': self.minimum_batch_size,
'maximum_batch_size': self.maximum_batch_size,
'optimal_production_temperature': self.optimal_production_temperature,
'optimal_humidity': self.optimal_humidity,
'quality_check_points': self.quality_check_points,
'common_issues': self.common_issues,
'status': self.status.value if self.status else None,
'is_seasonal': self.is_seasonal,
'season_start_month': self.season_start_month,
'season_end_month': self.season_end_month,
'is_signature_item': self.is_signature_item,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'created_by': str(self.created_by) if self.created_by else None,
'updated_by': str(self.updated_by) if self.updated_by else None,
}
class RecipeIngredient(Base):
"""Ingredients required for each recipe"""
__tablename__ = "recipe_ingredients"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
recipe_id = Column(UUID(as_uuid=True), ForeignKey('recipes.id'), nullable=False, index=True)
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Links to inventory ingredients
# Quantity specifications
quantity = Column(Float, nullable=False)
unit = Column(SQLEnum(MeasurementUnit), nullable=False)
quantity_in_base_unit = Column(Float, nullable=True) # Converted to ingredient's base unit
# Alternative measurements
alternative_quantity = Column(Float, nullable=True) # e.g., "2 cups" vs "240ml"
alternative_unit = Column(SQLEnum(MeasurementUnit), nullable=True)
# Ingredient specifications
preparation_method = Column(String(255), nullable=True) # "sifted", "room temperature", "chopped"
ingredient_notes = Column(Text, nullable=True) # Special instructions for this ingredient
is_optional = Column(Boolean, default=False)
# Recipe organization
ingredient_order = Column(Integer, nullable=False, default=1) # Order in recipe
ingredient_group = Column(String(100), nullable=True) # "wet ingredients", "dry ingredients", etc.
# Substitutions
substitution_options = Column(JSONB, nullable=True) # Alternative ingredients
substitution_ratio = Column(Float, nullable=True) # 1:1, 1:2, etc.
# Cost tracking
unit_cost = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=True)
cost_updated_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
recipe = relationship("Recipe", back_populates="ingredients")
__table_args__ = (
Index('idx_recipe_ingredients_recipe', 'recipe_id', 'ingredient_order'),
Index('idx_recipe_ingredients_ingredient', 'ingredient_id'),
Index('idx_recipe_ingredients_tenant', 'tenant_id', 'recipe_id'),
Index('idx_recipe_ingredients_group', 'recipe_id', 'ingredient_group', 'ingredient_order'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'recipe_id': str(self.recipe_id),
'ingredient_id': str(self.ingredient_id),
'quantity': self.quantity,
'unit': self.unit.value if self.unit else None,
'quantity_in_base_unit': self.quantity_in_base_unit,
'alternative_quantity': self.alternative_quantity,
'alternative_unit': self.alternative_unit.value if self.alternative_unit else None,
'preparation_method': self.preparation_method,
'ingredient_notes': self.ingredient_notes,
'is_optional': self.is_optional,
'ingredient_order': self.ingredient_order,
'ingredient_group': self.ingredient_group,
'substitution_options': self.substitution_options,
'substitution_ratio': self.substitution_ratio,
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
'total_cost': float(self.total_cost) if self.total_cost else None,
'cost_updated_at': self.cost_updated_at.isoformat() if self.cost_updated_at else None,
}
class ProductionBatch(Base):
"""Track production batches and inventory consumption"""
__tablename__ = "production_batches"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
recipe_id = Column(UUID(as_uuid=True), ForeignKey('recipes.id'), nullable=False, index=True)
# Batch identification
batch_number = Column(String(100), nullable=False, index=True)
production_date = Column(DateTime(timezone=True), nullable=False, index=True)
planned_start_time = Column(DateTime(timezone=True), nullable=True)
actual_start_time = Column(DateTime(timezone=True), nullable=True)
planned_end_time = Column(DateTime(timezone=True), nullable=True)
actual_end_time = Column(DateTime(timezone=True), nullable=True)
# Production planning
planned_quantity = Column(Float, nullable=False)
actual_quantity = Column(Float, nullable=True)
yield_percentage = Column(Float, nullable=True) # actual/planned * 100
batch_size_multiplier = Column(Float, nullable=False, default=1.0)
# Production details
status = Column(SQLEnum(ProductionStatus), nullable=False, default=ProductionStatus.PLANNED, index=True)
priority = Column(SQLEnum(ProductionPriority), nullable=False, default=ProductionPriority.NORMAL)
assigned_staff = Column(JSONB, nullable=True) # List of staff assigned to this batch
production_notes = Column(Text, nullable=True)
# Quality metrics
quality_score = Column(Float, nullable=True) # 1-10 scale
quality_notes = Column(Text, nullable=True)
defect_rate = Column(Float, nullable=True) # Percentage of defective products
rework_required = Column(Boolean, default=False)
# Cost tracking
planned_material_cost = Column(Numeric(10, 2), nullable=True)
actual_material_cost = Column(Numeric(10, 2), nullable=True)
labor_cost = Column(Numeric(10, 2), nullable=True)
overhead_cost = Column(Numeric(10, 2), nullable=True)
total_production_cost = Column(Numeric(10, 2), nullable=True)
cost_per_unit = Column(Numeric(10, 2), nullable=True)
# Environmental conditions
production_temperature = Column(Float, nullable=True)
production_humidity = Column(Float, nullable=True)
oven_temperature = Column(Float, nullable=True)
baking_time_minutes = Column(Integer, nullable=True)
# Waste and efficiency
waste_quantity = Column(Float, nullable=False, default=0.0)
waste_reason = Column(String(255), nullable=True)
efficiency_percentage = Column(Float, nullable=True) # Based on time vs planned
# Sales integration
customer_order_reference = Column(String(100), nullable=True) # If made to order
pre_order_quantity = Column(Float, nullable=True) # Pre-sold quantity
shelf_quantity = Column(Float, nullable=True) # For shelf/display
# Audit fields
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
created_by = Column(UUID(as_uuid=True), nullable=True)
completed_by = Column(UUID(as_uuid=True), nullable=True)
# Relationships
recipe = relationship("Recipe", back_populates="production_batches")
ingredient_consumptions = relationship("ProductionIngredientConsumption", back_populates="production_batch", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_production_batches_tenant_date', 'tenant_id', 'production_date'),
Index('idx_production_batches_recipe', 'recipe_id', 'production_date'),
Index('idx_production_batches_status', 'tenant_id', 'status', 'production_date'),
Index('idx_production_batches_batch_number', 'tenant_id', 'batch_number'),
Index('idx_production_batches_priority', 'tenant_id', 'priority', 'planned_start_time'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'recipe_id': str(self.recipe_id),
'batch_number': self.batch_number,
'production_date': self.production_date.isoformat() if self.production_date else None,
'planned_start_time': self.planned_start_time.isoformat() if self.planned_start_time else None,
'actual_start_time': self.actual_start_time.isoformat() if self.actual_start_time else None,
'planned_end_time': self.planned_end_time.isoformat() if self.planned_end_time else None,
'actual_end_time': self.actual_end_time.isoformat() if self.actual_end_time else None,
'planned_quantity': self.planned_quantity,
'actual_quantity': self.actual_quantity,
'yield_percentage': self.yield_percentage,
'batch_size_multiplier': self.batch_size_multiplier,
'status': self.status.value if self.status else None,
'priority': self.priority.value if self.priority else None,
'assigned_staff': self.assigned_staff,
'production_notes': self.production_notes,
'quality_score': self.quality_score,
'quality_notes': self.quality_notes,
'defect_rate': self.defect_rate,
'rework_required': self.rework_required,
'planned_material_cost': float(self.planned_material_cost) if self.planned_material_cost else None,
'actual_material_cost': float(self.actual_material_cost) if self.actual_material_cost else None,
'labor_cost': float(self.labor_cost) if self.labor_cost else None,
'overhead_cost': float(self.overhead_cost) if self.overhead_cost else None,
'total_production_cost': float(self.total_production_cost) if self.total_production_cost else None,
'cost_per_unit': float(self.cost_per_unit) if self.cost_per_unit else None,
'production_temperature': self.production_temperature,
'production_humidity': self.production_humidity,
'oven_temperature': self.oven_temperature,
'baking_time_minutes': self.baking_time_minutes,
'waste_quantity': self.waste_quantity,
'waste_reason': self.waste_reason,
'efficiency_percentage': self.efficiency_percentage,
'customer_order_reference': self.customer_order_reference,
'pre_order_quantity': self.pre_order_quantity,
'shelf_quantity': self.shelf_quantity,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'created_by': str(self.created_by) if self.created_by else None,
'completed_by': str(self.completed_by) if self.completed_by else None,
}
class ProductionIngredientConsumption(Base):
"""Track actual ingredient consumption during production"""
__tablename__ = "production_ingredient_consumption"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
production_batch_id = Column(UUID(as_uuid=True), ForeignKey('production_batches.id'), nullable=False, index=True)
recipe_ingredient_id = Column(UUID(as_uuid=True), ForeignKey('recipe_ingredients.id'), nullable=False, index=True)
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Links to inventory ingredients
stock_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Specific stock batch used
# Consumption details
planned_quantity = Column(Float, nullable=False)
actual_quantity = Column(Float, nullable=False)
unit = Column(SQLEnum(MeasurementUnit), nullable=False)
variance_quantity = Column(Float, nullable=True) # actual - planned
variance_percentage = Column(Float, nullable=True) # (actual - planned) / planned * 100
# Cost tracking
unit_cost = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=True)
# Consumption details
consumption_time = Column(DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc))
consumption_notes = Column(Text, nullable=True)
staff_member = Column(UUID(as_uuid=True), nullable=True)
# Quality and condition
ingredient_condition = Column(String(50), nullable=True) # fresh, near_expiry, etc.
quality_impact = Column(String(255), nullable=True) # Impact on final product quality
substitution_used = Column(Boolean, default=False)
substitution_details = Column(Text, nullable=True)
# Relationships
production_batch = relationship("ProductionBatch", back_populates="ingredient_consumptions")
__table_args__ = (
Index('idx_consumption_batch', 'production_batch_id'),
Index('idx_consumption_ingredient', 'ingredient_id', 'consumption_time'),
Index('idx_consumption_tenant', 'tenant_id', 'consumption_time'),
Index('idx_consumption_recipe_ingredient', 'recipe_ingredient_id'),
Index('idx_consumption_stock', 'stock_id'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'production_batch_id': str(self.production_batch_id),
'recipe_ingredient_id': str(self.recipe_ingredient_id),
'ingredient_id': str(self.ingredient_id),
'stock_id': str(self.stock_id) if self.stock_id else None,
'planned_quantity': self.planned_quantity,
'actual_quantity': self.actual_quantity,
'unit': self.unit.value if self.unit else None,
'variance_quantity': self.variance_quantity,
'variance_percentage': self.variance_percentage,
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
'total_cost': float(self.total_cost) if self.total_cost else None,
'consumption_time': self.consumption_time.isoformat() if self.consumption_time else None,
'consumption_notes': self.consumption_notes,
'staff_member': str(self.staff_member) if self.staff_member else None,
'ingredient_condition': self.ingredient_condition,
'quality_impact': self.quality_impact,
'substitution_used': self.substitution_used,
'substitution_details': self.substitution_details,
}
class ProductionSchedule(Base):
"""Production planning and scheduling"""
__tablename__ = "production_schedules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Schedule details
schedule_date = Column(DateTime(timezone=True), nullable=False, index=True)
schedule_name = Column(String(255), nullable=True)
# Production planning
total_planned_batches = Column(Integer, nullable=False, default=0)
total_planned_items = Column(Float, nullable=False, default=0.0)
estimated_production_hours = Column(Float, nullable=True)
estimated_material_cost = Column(Numeric(10, 2), nullable=True)
# Schedule status
is_published = Column(Boolean, default=False)
is_completed = Column(Boolean, default=False)
completion_percentage = Column(Float, nullable=True)
# Planning constraints
available_staff_hours = Column(Float, nullable=True)
oven_capacity_hours = Column(Float, nullable=True)
production_capacity_limit = Column(Float, nullable=True)
# Notes and instructions
schedule_notes = Column(Text, nullable=True)
preparation_instructions = Column(Text, nullable=True)
special_requirements = Column(JSONB, nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
created_by = Column(UUID(as_uuid=True), nullable=True)
published_by = Column(UUID(as_uuid=True), nullable=True)
published_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_production_schedules_tenant_date', 'tenant_id', 'schedule_date'),
Index('idx_production_schedules_published', 'tenant_id', 'is_published', 'schedule_date'),
Index('idx_production_schedules_completed', 'tenant_id', 'is_completed', 'schedule_date'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'schedule_date': self.schedule_date.isoformat() if self.schedule_date else None,
'schedule_name': self.schedule_name,
'total_planned_batches': self.total_planned_batches,
'total_planned_items': self.total_planned_items,
'estimated_production_hours': self.estimated_production_hours,
'estimated_material_cost': float(self.estimated_material_cost) if self.estimated_material_cost else None,
'is_published': self.is_published,
'is_completed': self.is_completed,
'completion_percentage': self.completion_percentage,
'available_staff_hours': self.available_staff_hours,
'oven_capacity_hours': self.oven_capacity_hours,
'production_capacity_limit': self.production_capacity_limit,
'schedule_notes': self.schedule_notes,
'preparation_instructions': self.preparation_instructions,
'special_requirements': self.special_requirements,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'created_by': str(self.created_by) if self.created_by else None,
'published_by': str(self.published_by) if self.published_by else None,
'published_at': self.published_at.isoformat() if self.published_at else None,
}

View File

@@ -0,0 +1,11 @@
# services/recipes/app/repositories/__init__.py
from .base import BaseRepository
from .recipe_repository import RecipeRepository
from .production_repository import ProductionRepository
__all__ = [
"BaseRepository",
"RecipeRepository",
"ProductionRepository"
]

View File

@@ -0,0 +1,96 @@
# services/recipes/app/repositories/base.py
"""
Base repository class for common database operations
"""
from typing import TypeVar, Generic, List, Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc
from uuid import UUID
T = TypeVar('T')
class BaseRepository(Generic[T]):
"""Base repository with common CRUD operations"""
def __init__(self, model: type, db: Session):
self.model = model
self.db = db
def create(self, obj_data: Dict[str, Any]) -> T:
"""Create a new record"""
db_obj = self.model(**obj_data)
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def get_by_id(self, record_id: UUID) -> Optional[T]:
"""Get record by ID"""
return self.db.query(self.model).filter(self.model.id == record_id).first()
def get_by_tenant_id(self, tenant_id: UUID, limit: int = 100, offset: int = 0) -> List[T]:
"""Get records by tenant ID with pagination"""
return (
self.db.query(self.model)
.filter(self.model.tenant_id == tenant_id)
.limit(limit)
.offset(offset)
.all()
)
def update(self, record_id: UUID, update_data: Dict[str, Any]) -> Optional[T]:
"""Update record by ID"""
db_obj = self.get_by_id(record_id)
if db_obj:
for key, value in update_data.items():
if hasattr(db_obj, key):
setattr(db_obj, key, value)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def delete(self, record_id: UUID) -> bool:
"""Delete record by ID"""
db_obj = self.get_by_id(record_id)
if db_obj:
self.db.delete(db_obj)
self.db.commit()
return True
return False
def count_by_tenant(self, tenant_id: UUID) -> int:
"""Count records by tenant"""
return self.db.query(self.model).filter(self.model.tenant_id == tenant_id).count()
def list_with_filters(
self,
tenant_id: UUID,
filters: Optional[Dict[str, Any]] = None,
sort_by: str = "created_at",
sort_order: str = "desc",
limit: int = 100,
offset: int = 0
) -> List[T]:
"""List records with filtering and sorting"""
query = self.db.query(self.model).filter(self.model.tenant_id == tenant_id)
# Apply filters
if filters:
for key, value in filters.items():
if hasattr(self.model, key) and value is not None:
query = query.filter(getattr(self.model, key) == value)
# Apply sorting
if hasattr(self.model, sort_by):
if sort_order.lower() == "desc":
query = query.order_by(desc(getattr(self.model, sort_by)))
else:
query = query.order_by(asc(getattr(self.model, sort_by)))
return query.limit(limit).offset(offset).all()
def exists(self, record_id: UUID) -> bool:
"""Check if record exists"""
return self.db.query(self.model).filter(self.model.id == record_id).first() is not None

View File

@@ -0,0 +1,382 @@
# services/recipes/app/repositories/production_repository.py
"""
Repository for production-related database operations
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func, desc, asc
from uuid import UUID
from datetime import datetime, date, timedelta
from .base import BaseRepository
from ..models.recipes import (
ProductionBatch,
ProductionIngredientConsumption,
ProductionSchedule,
ProductionStatus,
ProductionPriority
)
class ProductionRepository(BaseRepository[ProductionBatch]):
"""Repository for production batch operations"""
def __init__(self, db: Session):
super().__init__(ProductionBatch, db)
def get_by_id_with_consumptions(self, batch_id: UUID) -> Optional[ProductionBatch]:
"""Get production batch with ingredient consumptions loaded"""
return (
self.db.query(ProductionBatch)
.options(joinedload(ProductionBatch.ingredient_consumptions))
.filter(ProductionBatch.id == batch_id)
.first()
)
def get_batches_by_date_range(
self,
tenant_id: UUID,
start_date: date,
end_date: date,
status: Optional[ProductionStatus] = None
) -> List[ProductionBatch]:
"""Get production batches within date range"""
query = (
self.db.query(ProductionBatch)
.filter(
and_(
ProductionBatch.tenant_id == tenant_id,
ProductionBatch.production_date >= start_date,
ProductionBatch.production_date <= end_date
)
)
)
if status:
query = query.filter(ProductionBatch.status == status)
return query.order_by(ProductionBatch.production_date, ProductionBatch.planned_start_time).all()
def get_active_batches(self, tenant_id: UUID) -> List[ProductionBatch]:
"""Get all active production batches"""
return (
self.db.query(ProductionBatch)
.filter(
and_(
ProductionBatch.tenant_id == tenant_id,
ProductionBatch.status.in_([
ProductionStatus.PLANNED,
ProductionStatus.IN_PROGRESS
])
)
)
.order_by(ProductionBatch.planned_start_time)
.all()
)
def get_batches_by_recipe(
self,
tenant_id: UUID,
recipe_id: UUID,
limit: int = 50
) -> List[ProductionBatch]:
"""Get recent production batches for a specific recipe"""
return (
self.db.query(ProductionBatch)
.filter(
and_(
ProductionBatch.tenant_id == tenant_id,
ProductionBatch.recipe_id == recipe_id
)
)
.order_by(desc(ProductionBatch.production_date))
.limit(limit)
.all()
)
def search_batches(
self,
tenant_id: UUID,
search_term: Optional[str] = None,
status: Optional[ProductionStatus] = None,
priority: Optional[ProductionPriority] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
recipe_id: Optional[UUID] = None,
limit: int = 100,
offset: int = 0
) -> List[ProductionBatch]:
"""Search production batches with filters"""
query = self.db.query(ProductionBatch).filter(ProductionBatch.tenant_id == tenant_id)
# Text search
if search_term:
query = query.filter(ProductionBatch.batch_number.ilike(f"%{search_term}%"))
# Status filter
if status:
query = query.filter(ProductionBatch.status == status)
# Priority filter
if priority:
query = query.filter(ProductionBatch.priority == priority)
# Date range filter
if start_date:
query = query.filter(ProductionBatch.production_date >= start_date)
if end_date:
query = query.filter(ProductionBatch.production_date <= end_date)
# Recipe filter
if recipe_id:
query = query.filter(ProductionBatch.recipe_id == recipe_id)
return (
query.order_by(desc(ProductionBatch.production_date))
.limit(limit)
.offset(offset)
.all()
)
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"""
query = self.db.query(ProductionBatch).filter(ProductionBatch.tenant_id == tenant_id)
if start_date:
query = query.filter(ProductionBatch.production_date >= start_date)
if end_date:
query = query.filter(ProductionBatch.production_date <= end_date)
# Total batches
total_batches = query.count()
# Completed batches
completed_batches = query.filter(ProductionBatch.status == ProductionStatus.COMPLETED).count()
# Failed batches
failed_batches = query.filter(ProductionBatch.status == ProductionStatus.FAILED).count()
# Average yield
avg_yield = (
self.db.query(func.avg(ProductionBatch.yield_percentage))
.filter(
and_(
ProductionBatch.tenant_id == tenant_id,
ProductionBatch.status == ProductionStatus.COMPLETED,
ProductionBatch.yield_percentage.isnot(None)
)
)
.scalar() or 0
)
# Average quality score
avg_quality = (
self.db.query(func.avg(ProductionBatch.quality_score))
.filter(
and_(
ProductionBatch.tenant_id == tenant_id,
ProductionBatch.status == ProductionStatus.COMPLETED,
ProductionBatch.quality_score.isnot(None)
)
)
.scalar() or 0
)
# Total production cost
total_cost = (
self.db.query(func.sum(ProductionBatch.total_production_cost))
.filter(
and_(
ProductionBatch.tenant_id == tenant_id,
ProductionBatch.status == ProductionStatus.COMPLETED,
ProductionBatch.total_production_cost.isnot(None)
)
)
.scalar() or 0
)
# Status breakdown
status_stats = (
self.db.query(ProductionBatch.status, func.count(ProductionBatch.id))
.filter(ProductionBatch.tenant_id == tenant_id)
.group_by(ProductionBatch.status)
.all()
)
return {
"total_batches": total_batches,
"completed_batches": completed_batches,
"failed_batches": failed_batches,
"success_rate": (completed_batches / total_batches * 100) if total_batches > 0 else 0,
"average_yield_percentage": float(avg_yield) if avg_yield else 0,
"average_quality_score": float(avg_quality) if avg_quality else 0,
"total_production_cost": float(total_cost) if total_cost else 0,
"status_breakdown": [
{"status": status.value, "count": count}
for status, count in status_stats
]
}
def update_batch_status(
self,
batch_id: UUID,
status: ProductionStatus,
completed_by: Optional[UUID] = None,
notes: Optional[str] = None
) -> Optional[ProductionBatch]:
"""Update production batch status"""
batch = self.get_by_id(batch_id)
if batch:
batch.status = status
if status == ProductionStatus.COMPLETED and completed_by:
batch.completed_by = completed_by
batch.actual_end_time = datetime.utcnow()
if status == ProductionStatus.IN_PROGRESS and not batch.actual_start_time:
batch.actual_start_time = datetime.utcnow()
if notes:
batch.production_notes = notes
self.db.commit()
self.db.refresh(batch)
return batch
class ProductionIngredientConsumptionRepository(BaseRepository[ProductionIngredientConsumption]):
"""Repository for production ingredient consumption operations"""
def __init__(self, db: Session):
super().__init__(ProductionIngredientConsumption, db)
def get_by_batch_id(self, batch_id: UUID) -> List[ProductionIngredientConsumption]:
"""Get all ingredient consumptions for a production batch"""
return (
self.db.query(ProductionIngredientConsumption)
.filter(ProductionIngredientConsumption.production_batch_id == batch_id)
.all()
)
def get_by_ingredient_id(
self,
tenant_id: UUID,
ingredient_id: UUID,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> List[ProductionIngredientConsumption]:
"""Get ingredient consumptions by ingredient ID"""
query = (
self.db.query(ProductionIngredientConsumption)
.filter(
and_(
ProductionIngredientConsumption.tenant_id == tenant_id,
ProductionIngredientConsumption.ingredient_id == ingredient_id
)
)
)
if start_date:
query = query.filter(ProductionIngredientConsumption.consumption_time >= start_date)
if end_date:
query = query.filter(ProductionIngredientConsumption.consumption_time <= end_date)
return query.order_by(desc(ProductionIngredientConsumption.consumption_time)).all()
def calculate_ingredient_usage(
self,
tenant_id: UUID,
ingredient_id: UUID,
start_date: date,
end_date: date
) -> Dict[str, Any]:
"""Calculate ingredient usage statistics"""
consumptions = self.get_by_ingredient_id(tenant_id, ingredient_id, start_date, end_date)
if not consumptions:
return {
"total_consumed": 0,
"average_per_batch": 0,
"total_cost": 0,
"variance_percentage": 0,
"consumption_count": 0
}
total_consumed = sum(c.actual_quantity for c in consumptions)
total_planned = sum(c.planned_quantity for c in consumptions)
total_cost = sum((c.total_cost or 0) for c in consumptions)
variance_percentage = 0
if total_planned > 0:
variance_percentage = ((total_consumed - total_planned) / total_planned) * 100
return {
"total_consumed": total_consumed,
"total_planned": total_planned,
"average_per_batch": total_consumed / len(consumptions),
"total_cost": float(total_cost),
"variance_percentage": variance_percentage,
"consumption_count": len(consumptions)
}
class ProductionScheduleRepository(BaseRepository[ProductionSchedule]):
"""Repository for production schedule operations"""
def __init__(self, db: Session):
super().__init__(ProductionSchedule, db)
def get_by_date(self, tenant_id: UUID, schedule_date: date) -> Optional[ProductionSchedule]:
"""Get production schedule for specific date"""
return (
self.db.query(ProductionSchedule)
.filter(
and_(
ProductionSchedule.tenant_id == tenant_id,
ProductionSchedule.schedule_date == schedule_date
)
)
.first()
)
def get_published_schedules(
self,
tenant_id: UUID,
start_date: date,
end_date: date
) -> List[ProductionSchedule]:
"""Get published schedules within date range"""
return (
self.db.query(ProductionSchedule)
.filter(
and_(
ProductionSchedule.tenant_id == tenant_id,
ProductionSchedule.is_published == True,
ProductionSchedule.schedule_date >= start_date,
ProductionSchedule.schedule_date <= end_date
)
)
.order_by(ProductionSchedule.schedule_date)
.all()
)
def get_upcoming_schedules(self, tenant_id: UUID, days_ahead: int = 7) -> List[ProductionSchedule]:
"""Get upcoming production schedules"""
start_date = date.today()
end_date = date.today() + timedelta(days=days_ahead)
return (
self.db.query(ProductionSchedule)
.filter(
and_(
ProductionSchedule.tenant_id == tenant_id,
ProductionSchedule.schedule_date >= start_date,
ProductionSchedule.schedule_date <= end_date
)
)
.order_by(ProductionSchedule.schedule_date)
.all()
)

View File

@@ -0,0 +1,343 @@
# services/recipes/app/repositories/recipe_repository.py
"""
Repository for recipe-related database operations
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, or_, func
from uuid import UUID
from datetime import datetime
from .base import BaseRepository
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
class RecipeRepository(BaseRepository[Recipe]):
"""Repository for recipe operations"""
def __init__(self, db: Session):
super().__init__(Recipe, db)
def get_by_id_with_ingredients(self, recipe_id: UUID) -> Optional[Recipe]:
"""Get recipe with ingredients loaded"""
return (
self.db.query(Recipe)
.options(joinedload(Recipe.ingredients))
.filter(Recipe.id == recipe_id)
.first()
)
def get_by_finished_product_id(self, tenant_id: UUID, finished_product_id: UUID) -> Optional[Recipe]:
"""Get recipe by finished product ID"""
return (
self.db.query(Recipe)
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.finished_product_id == finished_product_id,
Recipe.status == RecipeStatus.ACTIVE
)
)
.first()
)
def search_recipes(
self,
tenant_id: UUID,
search_term: Optional[str] = None,
status: Optional[RecipeStatus] = 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[Recipe]:
"""Search recipes with multiple filters"""
query = self.db.query(Recipe).filter(Recipe.tenant_id == tenant_id)
# Text search
if search_term:
search_filter = or_(
Recipe.name.ilike(f"%{search_term}%"),
Recipe.description.ilike(f"%{search_term}%"),
Recipe.category.ilike(f"%{search_term}%")
)
query = query.filter(search_filter)
# Status filter
if status:
query = query.filter(Recipe.status == status)
# Category filter
if category:
query = query.filter(Recipe.category == category)
# Seasonal filter
if is_seasonal is not None:
query = query.filter(Recipe.is_seasonal == is_seasonal)
# Signature item filter
if is_signature is not None:
query = query.filter(Recipe.is_signature_item == is_signature)
# Difficulty level filter
if difficulty_level is not None:
query = query.filter(Recipe.difficulty_level == difficulty_level)
return query.order_by(Recipe.name).limit(limit).offset(offset).all()
def get_active_recipes(self, tenant_id: UUID) -> List[Recipe]:
"""Get all active recipes for tenant"""
return (
self.db.query(Recipe)
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.status == RecipeStatus.ACTIVE
)
)
.order_by(Recipe.name)
.all()
)
def get_seasonal_recipes(self, tenant_id: UUID, current_month: int) -> List[Recipe]:
"""Get seasonal recipes for current month"""
return (
self.db.query(Recipe)
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.status == RecipeStatus.ACTIVE,
Recipe.is_seasonal == True,
or_(
and_(
Recipe.season_start_month <= current_month,
Recipe.season_end_month >= current_month
),
and_(
Recipe.season_start_month > Recipe.season_end_month, # Crosses year boundary
or_(
Recipe.season_start_month <= current_month,
Recipe.season_end_month >= current_month
)
)
)
)
)
.order_by(Recipe.name)
.all()
)
def get_recipes_by_category(self, tenant_id: UUID, category: str) -> List[Recipe]:
"""Get recipes by category"""
return (
self.db.query(Recipe)
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.category == category,
Recipe.status == RecipeStatus.ACTIVE
)
)
.order_by(Recipe.name)
.all()
)
def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get recipe statistics for dashboard"""
total_recipes = self.db.query(Recipe).filter(Recipe.tenant_id == tenant_id).count()
active_recipes = (
self.db.query(Recipe)
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.status == RecipeStatus.ACTIVE
)
)
.count()
)
signature_recipes = (
self.db.query(Recipe)
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.is_signature_item == True,
Recipe.status == RecipeStatus.ACTIVE
)
)
.count()
)
seasonal_recipes = (
self.db.query(Recipe)
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.is_seasonal == True,
Recipe.status == RecipeStatus.ACTIVE
)
)
.count()
)
# Category breakdown
category_stats = (
self.db.query(Recipe.category, func.count(Recipe.id))
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.status == RecipeStatus.ACTIVE
)
)
.group_by(Recipe.category)
.all()
)
return {
"total_recipes": total_recipes,
"active_recipes": active_recipes,
"signature_recipes": signature_recipes,
"seasonal_recipes": seasonal_recipes,
"category_breakdown": [
{"category": cat, "count": count}
for cat, count in category_stats
]
}
def duplicate_recipe(self, recipe_id: UUID, new_name: str, created_by: UUID) -> Optional[Recipe]:
"""Create a duplicate of an existing recipe"""
original = self.get_by_id_with_ingredients(recipe_id)
if not original:
return None
# Create new recipe
recipe_data = {
"tenant_id": original.tenant_id,
"name": new_name,
"recipe_code": f"{original.recipe_code}_copy" if original.recipe_code else None,
"version": "1.0",
"finished_product_id": original.finished_product_id,
"description": original.description,
"category": original.category,
"cuisine_type": original.cuisine_type,
"difficulty_level": original.difficulty_level,
"yield_quantity": original.yield_quantity,
"yield_unit": original.yield_unit,
"prep_time_minutes": original.prep_time_minutes,
"cook_time_minutes": original.cook_time_minutes,
"total_time_minutes": original.total_time_minutes,
"rest_time_minutes": original.rest_time_minutes,
"instructions": original.instructions,
"preparation_notes": original.preparation_notes,
"storage_instructions": original.storage_instructions,
"quality_standards": original.quality_standards,
"serves_count": original.serves_count,
"nutritional_info": original.nutritional_info,
"allergen_info": original.allergen_info,
"dietary_tags": original.dietary_tags,
"batch_size_multiplier": original.batch_size_multiplier,
"minimum_batch_size": original.minimum_batch_size,
"maximum_batch_size": original.maximum_batch_size,
"optimal_production_temperature": original.optimal_production_temperature,
"optimal_humidity": original.optimal_humidity,
"quality_check_points": original.quality_check_points,
"common_issues": original.common_issues,
"status": RecipeStatus.DRAFT,
"is_seasonal": original.is_seasonal,
"season_start_month": original.season_start_month,
"season_end_month": original.season_end_month,
"is_signature_item": False,
"created_by": created_by
}
new_recipe = self.create(recipe_data)
# Copy ingredients
for ingredient in original.ingredients:
ingredient_data = {
"tenant_id": original.tenant_id,
"recipe_id": new_recipe.id,
"ingredient_id": ingredient.ingredient_id,
"quantity": ingredient.quantity,
"unit": ingredient.unit,
"quantity_in_base_unit": ingredient.quantity_in_base_unit,
"alternative_quantity": ingredient.alternative_quantity,
"alternative_unit": ingredient.alternative_unit,
"preparation_method": ingredient.preparation_method,
"ingredient_notes": ingredient.ingredient_notes,
"is_optional": ingredient.is_optional,
"ingredient_order": ingredient.ingredient_order,
"ingredient_group": ingredient.ingredient_group,
"substitution_options": ingredient.substitution_options,
"substitution_ratio": ingredient.substitution_ratio
}
recipe_ingredient = RecipeIngredient(**ingredient_data)
self.db.add(recipe_ingredient)
self.db.commit()
return new_recipe
class RecipeIngredientRepository(BaseRepository[RecipeIngredient]):
"""Repository for recipe ingredient operations"""
def __init__(self, db: Session):
super().__init__(RecipeIngredient, db)
def get_by_recipe_id(self, recipe_id: UUID) -> List[RecipeIngredient]:
"""Get all ingredients for a recipe"""
return (
self.db.query(RecipeIngredient)
.filter(RecipeIngredient.recipe_id == recipe_id)
.order_by(RecipeIngredient.ingredient_order)
.all()
)
def get_by_ingredient_group(self, recipe_id: UUID, ingredient_group: str) -> List[RecipeIngredient]:
"""Get ingredients by group within a recipe"""
return (
self.db.query(RecipeIngredient)
.filter(
and_(
RecipeIngredient.recipe_id == recipe_id,
RecipeIngredient.ingredient_group == ingredient_group
)
)
.order_by(RecipeIngredient.ingredient_order)
.all()
)
def update_ingredients_for_recipe(
self,
recipe_id: UUID,
ingredients_data: List[Dict[str, Any]]
) -> List[RecipeIngredient]:
"""Update all ingredients for a recipe"""
# Delete existing ingredients
self.db.query(RecipeIngredient).filter(
RecipeIngredient.recipe_id == recipe_id
).delete()
# Create new ingredients
new_ingredients = []
for ingredient_data in ingredients_data:
ingredient_data["recipe_id"] = recipe_id
ingredient = RecipeIngredient(**ingredient_data)
self.db.add(ingredient)
new_ingredients.append(ingredient)
self.db.commit()
return new_ingredients
def calculate_recipe_cost(self, recipe_id: UUID) -> float:
"""Calculate total cost of recipe based on ingredient costs"""
ingredients = self.get_by_recipe_id(recipe_id)
total_cost = sum(
(ingredient.total_cost or 0) for ingredient in ingredients
)
return total_cost

View File

@@ -0,0 +1,37 @@
# services/recipes/app/schemas/__init__.py
from .recipes import (
RecipeCreate,
RecipeUpdate,
RecipeResponse,
RecipeIngredientCreate,
RecipeIngredientResponse,
RecipeSearchRequest,
RecipeFeasibilityResponse
)
from .production import (
ProductionBatchCreate,
ProductionBatchUpdate,
ProductionBatchResponse,
ProductionIngredientConsumptionCreate,
ProductionIngredientConsumptionResponse,
ProductionScheduleCreate,
ProductionScheduleResponse
)
__all__ = [
"RecipeCreate",
"RecipeUpdate",
"RecipeResponse",
"RecipeIngredientCreate",
"RecipeIngredientResponse",
"RecipeSearchRequest",
"RecipeFeasibilityResponse",
"ProductionBatchCreate",
"ProductionBatchUpdate",
"ProductionBatchResponse",
"ProductionIngredientConsumptionCreate",
"ProductionIngredientConsumptionResponse",
"ProductionScheduleCreate",
"ProductionScheduleResponse"
]

View File

@@ -0,0 +1,257 @@
# services/recipes/app/schemas/production.py
"""
Pydantic schemas for production-related API requests and responses
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime, date
from enum import Enum
from ..models.recipes import ProductionStatus, ProductionPriority, MeasurementUnit
class ProductionIngredientConsumptionCreate(BaseModel):
"""Schema for creating production ingredient consumption"""
recipe_ingredient_id: UUID
ingredient_id: UUID
stock_id: Optional[UUID] = None
planned_quantity: float = Field(..., gt=0)
actual_quantity: float = Field(..., gt=0)
unit: MeasurementUnit
consumption_notes: Optional[str] = None
staff_member: Optional[UUID] = None
ingredient_condition: Optional[str] = None
quality_impact: Optional[str] = None
substitution_used: bool = False
substitution_details: Optional[str] = None
class ProductionIngredientConsumptionResponse(BaseModel):
"""Schema for production ingredient consumption responses"""
id: UUID
tenant_id: UUID
production_batch_id: UUID
recipe_ingredient_id: UUID
ingredient_id: UUID
stock_id: Optional[UUID] = None
planned_quantity: float
actual_quantity: float
unit: str
variance_quantity: Optional[float] = None
variance_percentage: Optional[float] = None
unit_cost: Optional[float] = None
total_cost: Optional[float] = None
consumption_time: datetime
consumption_notes: Optional[str] = None
staff_member: Optional[UUID] = None
ingredient_condition: Optional[str] = None
quality_impact: Optional[str] = None
substitution_used: bool
substitution_details: Optional[str] = None
class Config:
from_attributes = True
class ProductionBatchCreate(BaseModel):
"""Schema for creating production batches"""
recipe_id: UUID
batch_number: str = Field(..., min_length=1, max_length=100)
production_date: date
planned_start_time: Optional[datetime] = None
planned_end_time: Optional[datetime] = None
planned_quantity: float = Field(..., gt=0)
batch_size_multiplier: float = Field(default=1.0, gt=0)
priority: ProductionPriority = ProductionPriority.NORMAL
assigned_staff: Optional[List[UUID]] = None
production_notes: Optional[str] = None
customer_order_reference: Optional[str] = None
pre_order_quantity: Optional[float] = Field(None, ge=0)
shelf_quantity: Optional[float] = Field(None, ge=0)
class ProductionBatchUpdate(BaseModel):
"""Schema for updating production batches"""
batch_number: Optional[str] = Field(None, min_length=1, max_length=100)
production_date: Optional[date] = None
planned_start_time: Optional[datetime] = None
actual_start_time: Optional[datetime] = None
planned_end_time: Optional[datetime] = None
actual_end_time: Optional[datetime] = None
planned_quantity: Optional[float] = Field(None, gt=0)
actual_quantity: Optional[float] = Field(None, ge=0)
batch_size_multiplier: Optional[float] = Field(None, gt=0)
status: Optional[ProductionStatus] = None
priority: Optional[ProductionPriority] = None
assigned_staff: Optional[List[UUID]] = None
production_notes: Optional[str] = None
quality_score: Optional[float] = Field(None, ge=1, le=10)
quality_notes: Optional[str] = None
defect_rate: Optional[float] = Field(None, ge=0, le=100)
rework_required: Optional[bool] = None
labor_cost: Optional[float] = Field(None, ge=0)
overhead_cost: Optional[float] = Field(None, ge=0)
production_temperature: Optional[float] = None
production_humidity: Optional[float] = Field(None, ge=0, le=100)
oven_temperature: Optional[float] = None
baking_time_minutes: Optional[int] = Field(None, ge=0)
waste_quantity: Optional[float] = Field(None, ge=0)
waste_reason: Optional[str] = None
customer_order_reference: Optional[str] = None
pre_order_quantity: Optional[float] = Field(None, ge=0)
shelf_quantity: Optional[float] = Field(None, ge=0)
class ProductionBatchResponse(BaseModel):
"""Schema for production batch responses"""
id: UUID
tenant_id: UUID
recipe_id: UUID
batch_number: str
production_date: date
planned_start_time: Optional[datetime] = None
actual_start_time: Optional[datetime] = None
planned_end_time: Optional[datetime] = None
actual_end_time: Optional[datetime] = None
planned_quantity: float
actual_quantity: Optional[float] = None
yield_percentage: Optional[float] = None
batch_size_multiplier: float
status: str
priority: str
assigned_staff: Optional[List[UUID]] = None
production_notes: Optional[str] = None
quality_score: Optional[float] = None
quality_notes: Optional[str] = None
defect_rate: Optional[float] = None
rework_required: bool
planned_material_cost: Optional[float] = None
actual_material_cost: Optional[float] = None
labor_cost: Optional[float] = None
overhead_cost: Optional[float] = None
total_production_cost: Optional[float] = None
cost_per_unit: Optional[float] = None
production_temperature: Optional[float] = None
production_humidity: Optional[float] = None
oven_temperature: Optional[float] = None
baking_time_minutes: Optional[int] = None
waste_quantity: float
waste_reason: Optional[str] = None
efficiency_percentage: Optional[float] = None
customer_order_reference: Optional[str] = None
pre_order_quantity: Optional[float] = None
shelf_quantity: Optional[float] = None
created_at: datetime
updated_at: datetime
created_by: Optional[UUID] = None
completed_by: Optional[UUID] = None
ingredient_consumptions: Optional[List[ProductionIngredientConsumptionResponse]] = None
class Config:
from_attributes = True
class ProductionBatchSearchRequest(BaseModel):
"""Schema for production batch search requests"""
search_term: Optional[str] = None
status: Optional[ProductionStatus] = None
priority: Optional[ProductionPriority] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
recipe_id: Optional[UUID] = None
limit: int = Field(default=100, ge=1, le=1000)
offset: int = Field(default=0, ge=0)
class ProductionScheduleCreate(BaseModel):
"""Schema for creating production schedules"""
schedule_date: date
schedule_name: Optional[str] = Field(None, max_length=255)
estimated_production_hours: Optional[float] = Field(None, gt=0)
estimated_material_cost: Optional[float] = Field(None, ge=0)
available_staff_hours: Optional[float] = Field(None, gt=0)
oven_capacity_hours: Optional[float] = Field(None, gt=0)
production_capacity_limit: Optional[float] = Field(None, gt=0)
schedule_notes: Optional[str] = None
preparation_instructions: Optional[str] = None
special_requirements: Optional[Dict[str, Any]] = None
class ProductionScheduleUpdate(BaseModel):
"""Schema for updating production schedules"""
schedule_name: Optional[str] = Field(None, max_length=255)
total_planned_batches: Optional[int] = Field(None, ge=0)
total_planned_items: Optional[float] = Field(None, ge=0)
estimated_production_hours: Optional[float] = Field(None, gt=0)
estimated_material_cost: Optional[float] = Field(None, ge=0)
is_published: Optional[bool] = None
is_completed: Optional[bool] = None
completion_percentage: Optional[float] = Field(None, ge=0, le=100)
available_staff_hours: Optional[float] = Field(None, gt=0)
oven_capacity_hours: Optional[float] = Field(None, gt=0)
production_capacity_limit: Optional[float] = Field(None, gt=0)
schedule_notes: Optional[str] = None
preparation_instructions: Optional[str] = None
special_requirements: Optional[Dict[str, Any]] = None
class ProductionScheduleResponse(BaseModel):
"""Schema for production schedule responses"""
id: UUID
tenant_id: UUID
schedule_date: date
schedule_name: Optional[str] = None
total_planned_batches: int
total_planned_items: float
estimated_production_hours: Optional[float] = None
estimated_material_cost: Optional[float] = None
is_published: bool
is_completed: bool
completion_percentage: Optional[float] = None
available_staff_hours: Optional[float] = None
oven_capacity_hours: Optional[float] = None
production_capacity_limit: Optional[float] = None
schedule_notes: Optional[str] = None
preparation_instructions: Optional[str] = None
special_requirements: Optional[Dict[str, Any]] = None
created_at: datetime
updated_at: datetime
created_by: Optional[UUID] = None
published_by: Optional[UUID] = None
published_at: Optional[datetime] = None
class Config:
from_attributes = True
class ProductionStatisticsResponse(BaseModel):
"""Schema for production statistics responses"""
total_batches: int
completed_batches: int
failed_batches: int
success_rate: float
average_yield_percentage: float
average_quality_score: float
total_production_cost: float
status_breakdown: List[Dict[str, Any]]
class StartProductionRequest(BaseModel):
"""Schema for starting production batch"""
staff_member: Optional[UUID] = None
production_notes: Optional[str] = None
ingredient_consumptions: List[ProductionIngredientConsumptionCreate]
class CompleteProductionRequest(BaseModel):
"""Schema for completing production batch"""
actual_quantity: float = Field(..., gt=0)
quality_score: Optional[float] = Field(None, ge=1, le=10)
quality_notes: Optional[str] = None
defect_rate: Optional[float] = Field(None, ge=0, le=100)
waste_quantity: Optional[float] = Field(None, ge=0)
waste_reason: Optional[str] = None
production_notes: Optional[str] = None
staff_member: Optional[UUID] = None

View File

@@ -0,0 +1,237 @@
# services/recipes/app/schemas/recipes.py
"""
Pydantic schemas for recipe-related API requests and responses
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from enum import Enum
from ..models.recipes import RecipeStatus, MeasurementUnit
class RecipeIngredientCreate(BaseModel):
"""Schema for creating recipe ingredients"""
ingredient_id: UUID
quantity: float = Field(..., gt=0)
unit: MeasurementUnit
alternative_quantity: Optional[float] = None
alternative_unit: Optional[MeasurementUnit] = None
preparation_method: Optional[str] = None
ingredient_notes: Optional[str] = None
is_optional: bool = False
ingredient_order: int = Field(..., ge=1)
ingredient_group: Optional[str] = None
substitution_options: Optional[Dict[str, Any]] = None
substitution_ratio: Optional[float] = None
class RecipeIngredientUpdate(BaseModel):
"""Schema for updating recipe ingredients"""
ingredient_id: Optional[UUID] = None
quantity: Optional[float] = Field(None, gt=0)
unit: Optional[MeasurementUnit] = None
alternative_quantity: Optional[float] = None
alternative_unit: Optional[MeasurementUnit] = None
preparation_method: Optional[str] = None
ingredient_notes: Optional[str] = None
is_optional: Optional[bool] = None
ingredient_order: Optional[int] = Field(None, ge=1)
ingredient_group: Optional[str] = None
substitution_options: Optional[Dict[str, Any]] = None
substitution_ratio: Optional[float] = None
class RecipeIngredientResponse(BaseModel):
"""Schema for recipe ingredient responses"""
id: UUID
tenant_id: UUID
recipe_id: UUID
ingredient_id: UUID
quantity: float
unit: str
quantity_in_base_unit: Optional[float] = None
alternative_quantity: Optional[float] = None
alternative_unit: Optional[str] = None
preparation_method: Optional[str] = None
ingredient_notes: Optional[str] = None
is_optional: bool
ingredient_order: int
ingredient_group: Optional[str] = None
substitution_options: Optional[Dict[str, Any]] = None
substitution_ratio: Optional[float] = None
unit_cost: Optional[float] = None
total_cost: Optional[float] = None
cost_updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class RecipeCreate(BaseModel):
"""Schema for creating recipes"""
name: str = Field(..., min_length=1, max_length=255)
recipe_code: Optional[str] = Field(None, max_length=100)
version: str = Field(default="1.0", max_length=20)
finished_product_id: UUID
description: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
cuisine_type: Optional[str] = Field(None, max_length=100)
difficulty_level: int = Field(default=1, ge=1, le=5)
yield_quantity: float = Field(..., gt=0)
yield_unit: MeasurementUnit
prep_time_minutes: Optional[int] = Field(None, ge=0)
cook_time_minutes: Optional[int] = Field(None, ge=0)
total_time_minutes: Optional[int] = Field(None, ge=0)
rest_time_minutes: Optional[int] = Field(None, ge=0)
instructions: Optional[Dict[str, Any]] = None
preparation_notes: Optional[str] = None
storage_instructions: Optional[str] = None
quality_standards: Optional[str] = None
serves_count: Optional[int] = Field(None, ge=1)
nutritional_info: Optional[Dict[str, Any]] = None
allergen_info: Optional[Dict[str, Any]] = None
dietary_tags: Optional[Dict[str, Any]] = None
batch_size_multiplier: float = Field(default=1.0, gt=0)
minimum_batch_size: Optional[float] = Field(None, gt=0)
maximum_batch_size: Optional[float] = Field(None, gt=0)
optimal_production_temperature: Optional[float] = None
optimal_humidity: Optional[float] = Field(None, ge=0, le=100)
quality_check_points: Optional[Dict[str, Any]] = None
common_issues: Optional[Dict[str, Any]] = None
is_seasonal: bool = False
season_start_month: Optional[int] = Field(None, ge=1, le=12)
season_end_month: Optional[int] = Field(None, ge=1, le=12)
is_signature_item: bool = False
target_margin_percentage: Optional[float] = Field(None, ge=0)
ingredients: List[RecipeIngredientCreate] = Field(..., min_items=1)
class RecipeUpdate(BaseModel):
"""Schema for updating recipes"""
name: Optional[str] = Field(None, min_length=1, max_length=255)
recipe_code: Optional[str] = Field(None, max_length=100)
version: Optional[str] = Field(None, max_length=20)
description: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
cuisine_type: Optional[str] = Field(None, max_length=100)
difficulty_level: Optional[int] = Field(None, ge=1, le=5)
yield_quantity: Optional[float] = Field(None, gt=0)
yield_unit: Optional[MeasurementUnit] = None
prep_time_minutes: Optional[int] = Field(None, ge=0)
cook_time_minutes: Optional[int] = Field(None, ge=0)
total_time_minutes: Optional[int] = Field(None, ge=0)
rest_time_minutes: Optional[int] = Field(None, ge=0)
instructions: Optional[Dict[str, Any]] = None
preparation_notes: Optional[str] = None
storage_instructions: Optional[str] = None
quality_standards: Optional[str] = None
serves_count: Optional[int] = Field(None, ge=1)
nutritional_info: Optional[Dict[str, Any]] = None
allergen_info: Optional[Dict[str, Any]] = None
dietary_tags: Optional[Dict[str, Any]] = None
batch_size_multiplier: Optional[float] = Field(None, gt=0)
minimum_batch_size: Optional[float] = Field(None, gt=0)
maximum_batch_size: Optional[float] = Field(None, gt=0)
optimal_production_temperature: Optional[float] = None
optimal_humidity: Optional[float] = Field(None, ge=0, le=100)
quality_check_points: Optional[Dict[str, Any]] = None
common_issues: Optional[Dict[str, Any]] = None
status: Optional[RecipeStatus] = None
is_seasonal: Optional[bool] = None
season_start_month: Optional[int] = Field(None, ge=1, le=12)
season_end_month: Optional[int] = Field(None, ge=1, le=12)
is_signature_item: Optional[bool] = None
target_margin_percentage: Optional[float] = Field(None, ge=0)
ingredients: Optional[List[RecipeIngredientCreate]] = None
class RecipeResponse(BaseModel):
"""Schema for recipe responses"""
id: UUID
tenant_id: UUID
name: str
recipe_code: Optional[str] = None
version: str
finished_product_id: UUID
description: Optional[str] = None
category: Optional[str] = None
cuisine_type: Optional[str] = None
difficulty_level: int
yield_quantity: float
yield_unit: str
prep_time_minutes: Optional[int] = None
cook_time_minutes: Optional[int] = None
total_time_minutes: Optional[int] = None
rest_time_minutes: Optional[int] = None
estimated_cost_per_unit: Optional[float] = None
last_calculated_cost: Optional[float] = None
cost_calculation_date: Optional[datetime] = None
target_margin_percentage: Optional[float] = None
suggested_selling_price: Optional[float] = None
instructions: Optional[Dict[str, Any]] = None
preparation_notes: Optional[str] = None
storage_instructions: Optional[str] = None
quality_standards: Optional[str] = None
serves_count: Optional[int] = None
nutritional_info: Optional[Dict[str, Any]] = None
allergen_info: Optional[Dict[str, Any]] = None
dietary_tags: Optional[Dict[str, Any]] = None
batch_size_multiplier: float
minimum_batch_size: Optional[float] = None
maximum_batch_size: Optional[float] = None
optimal_production_temperature: Optional[float] = None
optimal_humidity: Optional[float] = None
quality_check_points: Optional[Dict[str, Any]] = None
common_issues: Optional[Dict[str, Any]] = None
status: str
is_seasonal: bool
season_start_month: Optional[int] = None
season_end_month: Optional[int] = None
is_signature_item: bool
created_at: datetime
updated_at: datetime
created_by: Optional[UUID] = None
updated_by: Optional[UUID] = None
ingredients: Optional[List[RecipeIngredientResponse]] = None
class Config:
from_attributes = True
class RecipeSearchRequest(BaseModel):
"""Schema for recipe search requests"""
search_term: Optional[str] = None
status: Optional[RecipeStatus] = None
category: Optional[str] = None
is_seasonal: Optional[bool] = None
is_signature: Optional[bool] = None
difficulty_level: Optional[int] = Field(None, ge=1, le=5)
limit: int = Field(default=100, ge=1, le=1000)
offset: int = Field(default=0, ge=0)
class RecipeDuplicateRequest(BaseModel):
"""Schema for recipe duplication requests"""
new_name: str = Field(..., min_length=1, max_length=255)
class RecipeFeasibilityResponse(BaseModel):
"""Schema for recipe feasibility check responses"""
recipe_id: UUID
recipe_name: str
batch_multiplier: float
feasible: bool
missing_ingredients: List[Dict[str, Any]] = []
insufficient_ingredients: List[Dict[str, Any]] = []
class RecipeStatisticsResponse(BaseModel):
"""Schema for recipe statistics responses"""
total_recipes: int
active_recipes: int
signature_recipes: int
seasonal_recipes: int
category_breakdown: List[Dict[str, Any]]

View File

@@ -0,0 +1,11 @@
# 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"
]

View File

@@ -0,0 +1,184 @@
# services/recipes/app/services/inventory_client.py
"""
Client for communicating with Inventory Service
"""
import httpx
import logging
from typing import List, Optional, Dict, Any
from uuid import UUID
from ..core.config import settings
logger = logging.getLogger(__name__)
class InventoryClient:
"""Client for inventory service communication"""
def __init__(self):
self.base_url = settings.INVENTORY_SERVICE_URL
self.timeout = 30.0
async def get_ingredient_by_id(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
"""Get ingredient details from inventory service"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/ingredients/{ingredient_id}",
headers={"X-Tenant-ID": str(tenant_id)}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
return None
else:
logger.error(f"Failed to get ingredient {ingredient_id}: {response.status_code}")
return None
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:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/ingredients/batch",
headers={"X-Tenant-ID": str(tenant_id)},
json={"ingredient_ids": [str(id) for id in ingredient_ids]}
)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Failed to get ingredients batch: {response.status_code}")
return []
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:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/stock/ingredient/{ingredient_id}",
headers={"X-Tenant-ID": str(tenant_id)}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
return None
else:
logger.error(f"Failed to get stock level for {ingredient_id}: {response.status_code}")
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:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/stock/consume",
headers={"X-Tenant-ID": str(tenant_id)},
json={
"consumptions": consumptions,
"reference_number": str(production_batch_id),
"movement_type": "production_use"
}
)
if response.status_code == 200:
return {"success": True, "data": response.json()}
else:
logger.error(f"Failed to consume ingredients: {response.status_code}")
return {"success": False, "error": response.text}
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:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/stock/add",
headers={"X-Tenant-ID": str(tenant_id)},
json=product_data
)
if response.status_code == 200:
return {"success": True, "data": response.json()}
else:
logger.error(f"Failed to add finished product: {response.status_code}")
return {"success": False, "error": response.text}
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)}

View File

@@ -0,0 +1,401 @@
# 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]

View File

@@ -0,0 +1,374 @@
# 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}")