Create the frontend receipes page to use real API

This commit is contained in:
Urtzi Alfaro
2025-09-19 21:39:04 +02:00
parent 8002d89d2b
commit d18c64ce6e
36 changed files with 3356 additions and 3171 deletions

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
Script to add sample recipes for testing
"""
import asyncio
import os
import sys
from datetime import datetime
from decimal import Decimal
# Add the app directory to Python path
sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
from core.database import get_db_session
from repositories.recipe_repository import RecipeRepository
from schemas.recipes import RecipeCreate, RecipeIngredientCreate
# Sample tenant ID - you should replace this with a real tenant ID from your system
SAMPLE_TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
# Sample finished product IDs - you should replace these with real product IDs from your system
SAMPLE_PRODUCT_IDS = [
"550e8400-e29b-41d4-a716-446655440001", # Pan Integral
"550e8400-e29b-41d4-a716-446655440002", # Croissant
"550e8400-e29b-41d4-a716-446655440003", # Tarta de Manzana
"550e8400-e29b-41d4-a716-446655440004", # Magdalenas
]
# Sample ingredient IDs - you should replace these with real ingredient IDs from your system
SAMPLE_INGREDIENT_IDS = [
"660e8400-e29b-41d4-a716-446655440001", # Harina integral
"660e8400-e29b-41d4-a716-446655440002", # Agua
"660e8400-e29b-41d4-a716-446655440003", # Levadura
"660e8400-e29b-41d4-a716-446655440004", # Sal
"660e8400-e29b-41d4-a716-446655440005", # Harina de fuerza
"660e8400-e29b-41d4-a716-446655440006", # Mantequilla
"660e8400-e29b-41d4-a716-446655440007", # Leche
"660e8400-e29b-41d4-a716-446655440008", # Azúcar
"660e8400-e29b-41d4-a716-446655440009", # Manzanas
"660e8400-e29b-41d4-a716-446655440010", # Huevos
"660e8400-e29b-41d4-a716-446655440011", # Limón
"660e8400-e29b-41d4-a716-446655440012", # Canela
]
async def add_sample_recipes():
"""Add sample recipes to the database"""
async with get_db_session() as session:
recipe_repo = RecipeRepository(session)
sample_recipes = [
{
"name": "Pan de Molde Integral",
"recipe_code": "PAN001",
"finished_product_id": SAMPLE_PRODUCT_IDS[0],
"description": "Pan integral artesanal con semillas, perfecto para desayunos saludables.",
"category": "bread",
"difficulty_level": 2,
"yield_quantity": 1,
"yield_unit": "units",
"prep_time_minutes": 120,
"cook_time_minutes": 35,
"total_time_minutes": 155,
"is_signature_item": False,
"target_margin_percentage": Decimal("40.0"),
"ingredients": [
{"ingredient_id": SAMPLE_INGREDIENT_IDS[0], "quantity": 500, "unit": "g", "is_optional": False, "ingredient_order": 1},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[1], "quantity": 300, "unit": "ml", "is_optional": False, "ingredient_order": 2},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[2], "quantity": 10, "unit": "g", "is_optional": False, "ingredient_order": 3},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[3], "quantity": 8, "unit": "g", "is_optional": False, "ingredient_order": 4},
]
},
{
"name": "Croissants de Mantequilla",
"recipe_code": "CRO001",
"finished_product_id": SAMPLE_PRODUCT_IDS[1],
"description": "Croissants franceses tradicionales con laminado de mantequilla.",
"category": "pastry",
"difficulty_level": 3,
"yield_quantity": 12,
"yield_unit": "units",
"prep_time_minutes": 480,
"cook_time_minutes": 20,
"total_time_minutes": 500,
"is_signature_item": True,
"target_margin_percentage": Decimal("52.8"),
"ingredients": [
{"ingredient_id": SAMPLE_INGREDIENT_IDS[4], "quantity": 500, "unit": "g", "is_optional": False, "ingredient_order": 1},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[5], "quantity": 250, "unit": "g", "is_optional": False, "ingredient_order": 2},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[6], "quantity": 150, "unit": "ml", "is_optional": False, "ingredient_order": 3},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[7], "quantity": 50, "unit": "g", "is_optional": False, "ingredient_order": 4},
]
},
{
"name": "Tarta de Manzana",
"recipe_code": "TAR001",
"finished_product_id": SAMPLE_PRODUCT_IDS[2],
"description": "Tarta casera de manzana con canela y masa quebrada.",
"category": "cake",
"difficulty_level": 1,
"yield_quantity": 8,
"yield_unit": "portions",
"prep_time_minutes": 45,
"cook_time_minutes": 40,
"total_time_minutes": 85,
"is_signature_item": False,
"target_margin_percentage": Decimal("65.0"),
"ingredients": [
{"ingredient_id": SAMPLE_INGREDIENT_IDS[8], "quantity": 1000, "unit": "g", "is_optional": False, "ingredient_order": 1},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[0], "quantity": 250, "unit": "g", "is_optional": False, "ingredient_order": 2},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[5], "quantity": 125, "unit": "g", "is_optional": False, "ingredient_order": 3},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[7], "quantity": 100, "unit": "g", "is_optional": False, "ingredient_order": 4},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[11], "quantity": 5, "unit": "g", "is_optional": True, "ingredient_order": 5},
]
},
{
"name": "Magdalenas de Limón",
"recipe_code": "MAG001",
"finished_product_id": SAMPLE_PRODUCT_IDS[3],
"description": "Magdalenas suaves y esponjosas con ralladura de limón.",
"category": "pastry",
"difficulty_level": 1,
"yield_quantity": 12,
"yield_unit": "units",
"prep_time_minutes": 20,
"cook_time_minutes": 25,
"total_time_minutes": 45,
"is_signature_item": False,
"target_margin_percentage": Decimal("57.8"),
"ingredients": [
{"ingredient_id": SAMPLE_INGREDIENT_IDS[0], "quantity": 200, "unit": "g", "is_optional": False, "ingredient_order": 1},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[9], "quantity": 3, "unit": "units", "is_optional": False, "ingredient_order": 2},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[7], "quantity": 150, "unit": "g", "is_optional": False, "ingredient_order": 3},
{"ingredient_id": SAMPLE_INGREDIENT_IDS[10], "quantity": 2, "unit": "units", "is_optional": False, "ingredient_order": 4},
]
}
]
for recipe_data in sample_recipes:
try:
# Prepare ingredients
ingredients = [
RecipeIngredientCreate(**ing_data)
for ing_data in recipe_data.pop("ingredients")
]
# Create recipe
recipe_create = RecipeCreate(
**recipe_data,
ingredients=ingredients
)
# Check if recipe already exists
existing_recipes = await recipe_repo.search_recipes(
tenant_id=SAMPLE_TENANT_ID,
search_term=recipe_data["name"]
)
recipe_exists = any(
recipe.name == recipe_data["name"]
for recipe in existing_recipes
)
if not recipe_exists:
recipe = await recipe_repo.create_recipe(
tenant_id=SAMPLE_TENANT_ID,
recipe_data=recipe_create
)
print(f"✅ Created recipe: {recipe.name}")
else:
print(f"⏭️ Recipe already exists: {recipe_data['name']}")
except Exception as e:
print(f"❌ Error creating recipe {recipe_data['name']}: {e}")
await session.commit()
print(f"\n🎉 Sample recipes setup completed!")
if __name__ == "__main__":
print("🧁 Adding sample recipes to database...")
print(f"📍 Tenant ID: {SAMPLE_TENANT_ID}")
print("=" * 50)
asyncio.run(add_sample_recipes())

View File

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

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

@@ -4,7 +4,7 @@ API endpoints for recipe management
"""
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from uuid import UUID
import logging
@@ -25,12 +25,6 @@ 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:
@@ -41,12 +35,12 @@ def get_user_id(x_user_id: str = Header(...)) -> UUID:
raise HTTPException(status_code=400, detail="Invalid user ID format")
@router.post("/", response_model=RecipeResponse)
@router.post("/{tenant_id}/recipes", response_model=RecipeResponse)
async def create_recipe(
tenant_id: UUID,
recipe_data: RecipeCreate,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
db: AsyncSession = Depends(get_db)
):
"""Create a new recipe"""
try:
@@ -76,16 +70,16 @@ async def create_recipe(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{recipe_id}", response_model=RecipeResponse)
@router.get("/{tenant_id}/recipes/{recipe_id}", response_model=RecipeResponse)
async def get_recipe(
tenant_id: UUID,
recipe_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
db: AsyncSession = Depends(get_db)
):
"""Get recipe by ID with ingredients"""
try:
recipe_service = RecipeService(db)
recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
@@ -103,20 +97,20 @@ async def get_recipe(
raise HTTPException(status_code=500, detail="Internal server error")
@router.put("/{recipe_id}", response_model=RecipeResponse)
@router.put("/{tenant_id}/recipes/{recipe_id}", response_model=RecipeResponse)
async def update_recipe(
tenant_id: UUID,
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)
db: AsyncSession = 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)
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
@@ -149,26 +143,26 @@ async def update_recipe(
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/{recipe_id}")
@router.delete("/{tenant_id}/recipes/{recipe_id}")
async def delete_recipe(
tenant_id: UUID,
recipe_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
db: AsyncSession = 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)
existing_recipe = await 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)
# Use service to delete
success = await recipe_service.delete_recipe(recipe_id)
if not success:
raise HTTPException(status_code=404, detail="Recipe not found")
@@ -181,9 +175,9 @@ async def delete_recipe(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/", response_model=List[RecipeResponse])
@router.get("/{tenant_id}/recipes", response_model=List[RecipeResponse])
async def search_recipes(
tenant_id: UUID = Depends(get_tenant_id),
tenant_id: UUID,
search_term: Optional[str] = Query(None),
status: Optional[str] = Query(None),
category: Optional[str] = Query(None),
@@ -192,13 +186,13 @@ async def search_recipes(
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)
db: AsyncSession = Depends(get_db)
):
"""Search recipes with filters"""
try:
recipe_service = RecipeService(db)
recipes = recipe_service.search_recipes(
recipes = await recipe_service.search_recipes(
tenant_id=tenant_id,
search_term=search_term,
status=status,
@@ -217,13 +211,13 @@ async def search_recipes(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse)
@router.post("/{tenant_id}/recipes/{recipe_id}/duplicate", response_model=RecipeResponse)
async def duplicate_recipe(
tenant_id: UUID,
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)
db: AsyncSession = Depends(get_db)
):
"""Create a duplicate of an existing recipe"""
try:
@@ -255,19 +249,19 @@ async def duplicate_recipe(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/{recipe_id}/activate", response_model=RecipeResponse)
@router.post("/{tenant_id}/recipes/{recipe_id}/activate", response_model=RecipeResponse)
async def activate_recipe(
tenant_id: UUID,
recipe_id: UUID,
tenant_id: UUID = Depends(get_tenant_id),
user_id: UUID = Depends(get_user_id),
db: Session = Depends(get_db)
db: AsyncSession = 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)
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
@@ -288,19 +282,19 @@ async def activate_recipe(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{recipe_id}/feasibility", response_model=RecipeFeasibilityResponse)
@router.get("/{tenant_id}/recipes/{recipe_id}/feasibility", response_model=RecipeFeasibilityResponse)
async def check_recipe_feasibility(
tenant_id: UUID,
recipe_id: UUID,
batch_multiplier: float = Query(1.0, gt=0),
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
db: AsyncSession = 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)
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
if not existing_recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
@@ -321,15 +315,15 @@ async def check_recipe_feasibility(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/statistics/dashboard", response_model=RecipeStatisticsResponse)
@router.get("/{tenant_id}/recipes/statistics/dashboard", response_model=RecipeStatisticsResponse)
async def get_recipe_statistics(
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
tenant_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get recipe statistics for dashboard"""
try:
recipe_service = RecipeService(db)
stats = recipe_service.get_recipe_statistics(tenant_id)
stats = await recipe_service.get_recipe_statistics(tenant_id)
return RecipeStatisticsResponse(**stats)
@@ -338,17 +332,17 @@ async def get_recipe_statistics(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/categories/list")
@router.get("/{tenant_id}/recipes/categories/list")
async def get_recipe_categories(
tenant_id: UUID = Depends(get_tenant_id),
db: Session = Depends(get_db)
tenant_id: UUID,
db: AsyncSession = 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)
recipes = await recipe_service.search_recipes(tenant_id, limit=1000)
categories = list(set(recipe["category"] for recipe in recipes if recipe["category"]))
categories.sort()

View File

@@ -17,7 +17,7 @@ class Settings:
# API settings
API_V1_PREFIX: str = "/api/v1"
# Database
# Override DATABASE_URL for recipes service
DATABASE_URL: str = os.getenv(
"RECIPES_DATABASE_URL",
"postgresql://recipes_user:recipes_pass@localhost:5432/recipes_db"
@@ -27,17 +27,19 @@ class Settings:
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
# External service URLs
GATEWAY_URL: str = os.getenv("GATEWAY_URL", "http://gateway:8000")
INVENTORY_SERVICE_URL: str = os.getenv(
"INVENTORY_SERVICE_URL",
"INVENTORY_SERVICE_URL",
"http://inventory:8000"
)
SALES_SERVICE_URL: str = os.getenv(
"SALES_SERVICE_URL",
"SALES_SERVICE_URL",
"http://sales:8000"
)
# Authentication
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here")
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "your-super-secret-jwt-key-change-in-production-min-32-characters-long")
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
# Logging

View File

@@ -17,4 +17,9 @@ db_manager = create_database_manager(
async def get_db():
"""FastAPI dependency to get database session"""
async for session in db_manager.get_db():
yield session
yield session
# Initialize database
async def init_database():
"""Initialize database tables"""
await db_manager.create_tables()

View File

@@ -14,7 +14,7 @@ from contextlib import asynccontextmanager
from .core.config import settings
from .core.database import db_manager
from .api import recipes, production, ingredients
from .api import recipes
# Import models to register them with SQLAlchemy metadata
from .models import recipes as recipe_models
@@ -121,24 +121,14 @@ async def health_check():
)
# Include API routers
# Include API routers with tenant-scoped paths
app.include_router(
recipes.router,
prefix=f"{settings.API_V1_PREFIX}/recipes",
prefix=f"{settings.API_V1_PREFIX}/tenants",
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("/")

View File

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

View File

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

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

@@ -1,343 +1,245 @@
# services/recipes/app/repositories/recipe_repository.py
"""
Repository for recipe-related database operations
Async recipe repository for database operations
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
from sqlalchemy.orm import selectinload
from uuid import UUID
from datetime import datetime
import structlog
from .base import BaseRepository
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
from shared.database.repository import BaseRepository
from ..models.recipes import Recipe, RecipeIngredient
from ..schemas.recipes import RecipeCreate, RecipeUpdate
logger = structlog.get_logger()
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]:
class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]):
"""Async repository for recipe operations"""
def __init__(self, session: AsyncSession):
super().__init__(Recipe, session)
async def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]:
"""Get recipe with ingredients loaded"""
return (
self.db.query(Recipe)
.options(joinedload(Recipe.ingredients))
.filter(Recipe.id == recipe_id)
.first()
result = await self.session.execute(
select(Recipe)
.options(selectinload(Recipe.ingredients))
.where(Recipe.id == recipe_id)
)
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(
recipe = result.scalar_one_or_none()
if not recipe:
return None
return {
"id": str(recipe.id),
"tenant_id": str(recipe.tenant_id),
"name": recipe.name,
"recipe_code": recipe.recipe_code,
"version": recipe.version,
"finished_product_id": str(recipe.finished_product_id),
"description": recipe.description,
"category": recipe.category,
"cuisine_type": recipe.cuisine_type,
"difficulty_level": recipe.difficulty_level,
"yield_quantity": recipe.yield_quantity,
"yield_unit": recipe.yield_unit.value if recipe.yield_unit else None,
"prep_time_minutes": recipe.prep_time_minutes,
"cook_time_minutes": recipe.cook_time_minutes,
"total_time_minutes": recipe.total_time_minutes,
"rest_time_minutes": recipe.rest_time_minutes,
"estimated_cost_per_unit": float(recipe.estimated_cost_per_unit) if recipe.estimated_cost_per_unit else None,
"last_calculated_cost": float(recipe.last_calculated_cost) if recipe.last_calculated_cost else None,
"cost_calculation_date": recipe.cost_calculation_date.isoformat() if recipe.cost_calculation_date else None,
"target_margin_percentage": recipe.target_margin_percentage,
"suggested_selling_price": float(recipe.suggested_selling_price) if recipe.suggested_selling_price else None,
"instructions": recipe.instructions,
"preparation_notes": recipe.preparation_notes,
"storage_instructions": recipe.storage_instructions,
"quality_standards": recipe.quality_standards,
"serves_count": recipe.serves_count,
"nutritional_info": recipe.nutritional_info,
"allergen_info": recipe.allergen_info,
"dietary_tags": recipe.dietary_tags,
"batch_size_multiplier": recipe.batch_size_multiplier,
"minimum_batch_size": recipe.minimum_batch_size,
"maximum_batch_size": recipe.maximum_batch_size,
"status": recipe.status,
"is_seasonal": recipe.is_seasonal,
"season_start_month": recipe.season_start_month,
"season_end_month": recipe.season_end_month,
"is_signature_item": recipe.is_signature_item,
"created_at": recipe.created_at.isoformat() if recipe.created_at else None,
"updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None,
"ingredients": [
{
"id": str(ingredient.id),
"ingredient_id": str(ingredient.ingredient_id),
"quantity": float(ingredient.quantity),
"unit": ingredient.unit,
"preparation_method": ingredient.preparation_method,
"notes": ingredient.notes
}
for ingredient in recipe.ingredients
] if hasattr(recipe, 'ingredients') else []
}
async def search_recipes(
self,
tenant_id: UUID,
search_term: Optional[str] = None,
status: Optional[RecipeStatus] = 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[Recipe]:
) -> List[Dict[str, Any]]:
"""Search recipes with multiple filters"""
query = self.db.query(Recipe).filter(Recipe.tenant_id == tenant_id)
query = select(Recipe).where(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.where(
or_(
Recipe.name.ilike(f"%{search_term}%"),
Recipe.description.ilike(f"%{search_term}%")
)
)
query = query.filter(search_filter)
# Status filter
if status:
query = query.filter(Recipe.status == status)
query = query.where(Recipe.status == status)
# Category filter
if category:
query = query.filter(Recipe.category == category)
query = query.where(Recipe.category == category)
# Seasonal filter
if is_seasonal is not None:
query = query.filter(Recipe.is_seasonal == is_seasonal)
# Signature item filter
query = query.where(Recipe.is_seasonal == is_seasonal)
# Signature filter
if is_signature is not None:
query = query.filter(Recipe.is_signature_item == is_signature)
# Difficulty level filter
query = query.where(Recipe.is_signature_item == is_signature)
# Difficulty 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]:
query = query.where(Recipe.difficulty_level == difficulty_level)
# Apply ordering and pagination
query = query.order_by(Recipe.name).limit(limit).offset(offset)
result = await self.session.execute(query)
recipes = result.scalars().all()
return [
{
"id": str(recipe.id),
"tenant_id": str(recipe.tenant_id),
"name": recipe.name,
"recipe_code": recipe.recipe_code,
"version": recipe.version,
"finished_product_id": str(recipe.finished_product_id),
"description": recipe.description,
"category": recipe.category,
"cuisine_type": recipe.cuisine_type,
"difficulty_level": recipe.difficulty_level,
"yield_quantity": recipe.yield_quantity,
"yield_unit": recipe.yield_unit.value if recipe.yield_unit else None,
"prep_time_minutes": recipe.prep_time_minutes,
"cook_time_minutes": recipe.cook_time_minutes,
"total_time_minutes": recipe.total_time_minutes,
"rest_time_minutes": recipe.rest_time_minutes,
"estimated_cost_per_unit": float(recipe.estimated_cost_per_unit) if recipe.estimated_cost_per_unit else None,
"last_calculated_cost": float(recipe.last_calculated_cost) if recipe.last_calculated_cost else None,
"cost_calculation_date": recipe.cost_calculation_date.isoformat() if recipe.cost_calculation_date else None,
"target_margin_percentage": recipe.target_margin_percentage,
"suggested_selling_price": float(recipe.suggested_selling_price) if recipe.suggested_selling_price else None,
"instructions": recipe.instructions,
"preparation_notes": recipe.preparation_notes,
"storage_instructions": recipe.storage_instructions,
"quality_standards": recipe.quality_standards,
"serves_count": recipe.serves_count,
"nutritional_info": recipe.nutritional_info,
"allergen_info": recipe.allergen_info,
"dietary_tags": recipe.dietary_tags,
"batch_size_multiplier": recipe.batch_size_multiplier,
"minimum_batch_size": recipe.minimum_batch_size,
"maximum_batch_size": recipe.maximum_batch_size,
"status": recipe.status,
"is_seasonal": recipe.is_seasonal,
"season_start_month": recipe.season_start_month,
"season_end_month": recipe.season_end_month,
"is_signature_item": recipe.is_signature_item,
"created_at": recipe.created_at.isoformat() if recipe.created_at else None,
"updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None
}
for recipe in recipes
]
async 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(
# Total recipes
total_result = await self.session.execute(
select(func.count(Recipe.id)).where(Recipe.tenant_id == tenant_id)
)
total_recipes = total_result.scalar() or 0
# Active recipes
active_result = await self.session.execute(
select(func.count(Recipe.id)).where(
and_(
Recipe.tenant_id == tenant_id,
Recipe.status == RecipeStatus.ACTIVE
Recipe.status == "active"
)
)
.count()
)
signature_recipes = (
self.db.query(Recipe)
.filter(
active_recipes = active_result.scalar() or 0
# Signature recipes
signature_result = await self.session.execute(
select(func.count(Recipe.id)).where(
and_(
Recipe.tenant_id == tenant_id,
Recipe.is_signature_item == True,
Recipe.status == RecipeStatus.ACTIVE
Recipe.is_signature_item == True
)
)
.count()
)
seasonal_recipes = (
self.db.query(Recipe)
.filter(
signature_recipes = signature_result.scalar() or 0
# Seasonal recipes
seasonal_result = await self.session.execute(
select(func.count(Recipe.id)).where(
and_(
Recipe.tenant_id == tenant_id,
Recipe.is_seasonal == True,
Recipe.status == RecipeStatus.ACTIVE
Recipe.is_seasonal == True
)
)
.count()
)
seasonal_recipes = seasonal_result.scalar() or 0
# Category breakdown
category_stats = (
self.db.query(Recipe.category, func.count(Recipe.id))
.filter(
and_(
Recipe.tenant_id == tenant_id,
Recipe.status == RecipeStatus.ACTIVE
)
)
category_result = await self.session.execute(
select(Recipe.category, func.count(Recipe.id))
.where(Recipe.tenant_id == tenant_id)
.group_by(Recipe.category)
.all()
)
categories = dict(category_result.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
"draft_recipes": total_recipes - active_recipes,
"categories": categories,
"average_difficulty": 3.0, # Could calculate from actual data
"total_categories": len(categories)
}

View File

@@ -7,31 +7,19 @@ from .recipes import (
RecipeIngredientCreate,
RecipeIngredientResponse,
RecipeSearchRequest,
RecipeFeasibilityResponse
)
from .production import (
ProductionBatchCreate,
ProductionBatchUpdate,
ProductionBatchResponse,
ProductionIngredientConsumptionCreate,
ProductionIngredientConsumptionResponse,
ProductionScheduleCreate,
ProductionScheduleResponse
RecipeFeasibilityResponse,
RecipeDuplicateRequest,
RecipeStatisticsResponse
)
__all__ = [
"RecipeCreate",
"RecipeUpdate",
"RecipeUpdate",
"RecipeResponse",
"RecipeIngredientCreate",
"RecipeIngredientResponse",
"RecipeSearchRequest",
"RecipeFeasibilityResponse",
"ProductionBatchCreate",
"ProductionBatchUpdate",
"ProductionBatchResponse",
"ProductionIngredientConsumptionCreate",
"ProductionIngredientConsumptionResponse",
"ProductionScheduleCreate",
"ProductionScheduleResponse"
"RecipeDuplicateRequest",
"RecipeStatisticsResponse"
]

View File

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

@@ -1,11 +1,7 @@
# services/recipes/app/services/__init__.py
from .recipe_service import RecipeService
from .production_service import ProductionService
from .inventory_client import InventoryClient
__all__ = [
"RecipeService",
"ProductionService",
"InventoryClient"
"RecipeService"
]

View File

@@ -1,151 +0,0 @@
# services/recipes/app/services/inventory_client.py
"""
Client for communicating with Inventory Service
"""
import logging
from typing import List, Optional, Dict, Any
from uuid import UUID
from shared.clients.inventory_client import InventoryServiceClient as SharedInventoryClient
from ..core.config import settings
logger = logging.getLogger(__name__)
class InventoryClient:
"""Client for inventory service communication via shared client"""
def __init__(self):
self._shared_client = SharedInventoryClient(settings)
async def get_ingredient_by_id(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
"""Get ingredient details from inventory service"""
try:
result = await self._shared_client.get_ingredient_by_id(ingredient_id, str(tenant_id))
return result
except Exception as e:
logger.error(f"Error getting ingredient {ingredient_id}: {e}")
return None
async def get_ingredients_by_ids(self, tenant_id: UUID, ingredient_ids: List[UUID]) -> List[Dict[str, Any]]:
"""Get multiple ingredients by IDs"""
try:
# For now, get ingredients individually - could be optimized with batch endpoint
results = []
for ingredient_id in ingredient_ids:
ingredient = await self._shared_client.get_ingredient_by_id(ingredient_id, str(tenant_id))
if ingredient:
results.append(ingredient)
return results
except Exception as e:
logger.error(f"Error getting ingredients batch: {e}")
return []
async def get_ingredient_stock_level(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
"""Get current stock level for ingredient"""
try:
stock_entries = await self._shared_client.get_ingredient_stock(ingredient_id, str(tenant_id))
if stock_entries:
# Calculate total available stock from all entries
total_stock = sum(entry.get('available_quantity', 0) for entry in stock_entries)
return {
'ingredient_id': str(ingredient_id),
'total_available': total_stock,
'stock_entries': stock_entries
}
return None
except Exception as e:
logger.error(f"Error getting stock level for {ingredient_id}: {e}")
return None
async def reserve_ingredients(
self,
tenant_id: UUID,
reservations: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""Reserve ingredients for production"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/stock/reserve",
headers={"X-Tenant-ID": str(tenant_id)},
json={"reservations": reservations}
)
if response.status_code == 200:
return {"success": True, "data": response.json()}
else:
logger.error(f"Failed to reserve ingredients: {response.status_code}")
return {"success": False, "error": response.text}
except Exception as e:
logger.error(f"Error reserving ingredients: {e}")
return {"success": False, "error": str(e)}
async def consume_ingredients(
self,
tenant_id: UUID,
consumptions: List[Dict[str, Any]],
production_batch_id: UUID
) -> Dict[str, Any]:
"""Record ingredient consumption for production"""
try:
consumption_data = {
"consumptions": consumptions,
"reference_number": str(production_batch_id),
"movement_type": "production_use"
}
result = await self._shared_client.consume_stock(consumption_data, str(tenant_id))
if result:
return {"success": True, "data": result}
else:
return {"success": False, "error": "Failed to consume ingredients"}
except Exception as e:
logger.error(f"Error consuming ingredients: {e}")
return {"success": False, "error": str(e)}
async def add_finished_product_to_inventory(
self,
tenant_id: UUID,
product_data: Dict[str, Any]
) -> Dict[str, Any]:
"""Add finished product to inventory after production"""
try:
result = await self._shared_client.receive_stock(product_data, str(tenant_id))
if result:
return {"success": True, "data": result}
else:
return {"success": False, "error": "Failed to add finished product"}
except Exception as e:
logger.error(f"Error adding finished product: {e}")
return {"success": False, "error": str(e)}
async def check_ingredient_availability(
self,
tenant_id: UUID,
required_ingredients: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""Check if required ingredients are available for production"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/stock/check-availability",
headers={"X-Tenant-ID": str(tenant_id)},
json={"required_ingredients": required_ingredients}
)
if response.status_code == 200:
return {"success": True, "data": response.json()}
else:
logger.error(f"Failed to check availability: {response.status_code}")
return {"success": False, "error": response.text}
except Exception as e:
logger.error(f"Error checking availability: {e}")
return {"success": False, "error": str(e)}

View File

@@ -1,401 +0,0 @@
# services/recipes/app/services/production_service.py
"""
Service layer for production management operations
"""
import logging
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime, date
from sqlalchemy.orm import Session
from ..repositories.production_repository import (
ProductionRepository,
ProductionIngredientConsumptionRepository,
ProductionScheduleRepository
)
from ..repositories.recipe_repository import RecipeRepository
from ..models.recipes import ProductionBatch, ProductionStatus, ProductionPriority
from .inventory_client import InventoryClient
from ..core.config import settings
logger = logging.getLogger(__name__)
class ProductionService:
"""Service for production management operations"""
def __init__(self, db: Session):
self.db = db
self.production_repo = ProductionRepository(db)
self.consumption_repo = ProductionIngredientConsumptionRepository(db)
self.schedule_repo = ProductionScheduleRepository(db)
self.recipe_repo = RecipeRepository(db)
self.inventory_client = InventoryClient()
async def create_production_batch(
self,
batch_data: Dict[str, Any],
created_by: UUID
) -> Dict[str, Any]:
"""Create a new production batch"""
try:
# Validate recipe exists and is active
recipe = self.recipe_repo.get_by_id(batch_data["recipe_id"])
if not recipe:
return {
"success": False,
"error": "Recipe not found"
}
if recipe.tenant_id != batch_data["tenant_id"]:
return {
"success": False,
"error": "Recipe does not belong to this tenant"
}
# Check recipe feasibility if needed
if batch_data.get("check_feasibility", True):
from .recipe_service import RecipeService
recipe_service = RecipeService(self.db)
feasibility = await recipe_service.check_recipe_feasibility(
recipe.id,
batch_data.get("batch_size_multiplier", 1.0)
)
if feasibility["success"] and not feasibility["data"]["feasible"]:
return {
"success": False,
"error": "Insufficient ingredients available for production",
"details": feasibility["data"]
}
# Generate batch number if not provided
if not batch_data.get("batch_number"):
date_str = datetime.now().strftime("%Y%m%d")
count = self.production_repo.count_by_tenant(batch_data["tenant_id"])
batch_data["batch_number"] = f"BATCH-{date_str}-{count + 1:04d}"
# Set defaults
batch_data["created_by"] = created_by
batch_data["status"] = ProductionStatus.PLANNED
batch = self.production_repo.create(batch_data)
return {
"success": True,
"data": batch.to_dict()
}
except Exception as e:
logger.error(f"Error creating production batch: {e}")
self.db.rollback()
return {
"success": False,
"error": str(e)
}
async def start_production_batch(
self,
batch_id: UUID,
ingredient_consumptions: List[Dict[str, Any]],
staff_member: UUID,
notes: Optional[str] = None
) -> Dict[str, Any]:
"""Start production batch and record ingredient consumptions"""
try:
batch = self.production_repo.get_by_id(batch_id)
if not batch:
return {
"success": False,
"error": "Production batch not found"
}
if batch.status != ProductionStatus.PLANNED:
return {
"success": False,
"error": f"Cannot start batch in {batch.status.value} status"
}
# Reserve ingredients in inventory
reservations = []
for consumption in ingredient_consumptions:
reservations.append({
"ingredient_id": str(consumption["ingredient_id"]),
"quantity": consumption["actual_quantity"],
"unit": consumption["unit"].value if hasattr(consumption["unit"], "value") else consumption["unit"],
"reference": str(batch_id)
})
reserve_result = await self.inventory_client.reserve_ingredients(
batch.tenant_id,
reservations
)
if not reserve_result["success"]:
return {
"success": False,
"error": f"Failed to reserve ingredients: {reserve_result['error']}"
}
# Update batch status
self.production_repo.update_batch_status(
batch_id,
ProductionStatus.IN_PROGRESS,
notes=notes
)
# Record ingredient consumptions
for consumption_data in ingredient_consumptions:
consumption_data["tenant_id"] = batch.tenant_id
consumption_data["production_batch_id"] = batch_id
consumption_data["staff_member"] = staff_member
consumption_data["consumption_time"] = datetime.utcnow()
# Calculate variance
planned = consumption_data["planned_quantity"]
actual = consumption_data["actual_quantity"]
consumption_data["variance_quantity"] = actual - planned
if planned > 0:
consumption_data["variance_percentage"] = ((actual - planned) / planned) * 100
self.consumption_repo.create(consumption_data)
# Get updated batch
updated_batch = self.production_repo.get_by_id_with_consumptions(batch_id)
return {
"success": True,
"data": updated_batch.to_dict()
}
except Exception as e:
logger.error(f"Error starting production batch {batch_id}: {e}")
self.db.rollback()
return {
"success": False,
"error": str(e)
}
async def complete_production_batch(
self,
batch_id: UUID,
completion_data: Dict[str, Any],
completed_by: UUID
) -> Dict[str, Any]:
"""Complete production batch and add finished products to inventory"""
try:
batch = self.production_repo.get_by_id_with_consumptions(batch_id)
if not batch:
return {
"success": False,
"error": "Production batch not found"
}
if batch.status != ProductionStatus.IN_PROGRESS:
return {
"success": False,
"error": f"Cannot complete batch in {batch.status.value} status"
}
# Calculate yield percentage
actual_quantity = completion_data["actual_quantity"]
yield_percentage = (actual_quantity / batch.planned_quantity) * 100
# Calculate efficiency percentage
efficiency_percentage = None
if batch.actual_start_time and batch.planned_start_time and batch.planned_end_time:
planned_duration = (batch.planned_end_time - batch.planned_start_time).total_seconds()
actual_duration = (datetime.utcnow() - batch.actual_start_time).total_seconds()
if actual_duration > 0:
efficiency_percentage = (planned_duration / actual_duration) * 100
# Update batch with completion data
update_data = {
"actual_quantity": actual_quantity,
"yield_percentage": yield_percentage,
"efficiency_percentage": efficiency_percentage,
"actual_end_time": datetime.utcnow(),
"completed_by": completed_by,
"status": ProductionStatus.COMPLETED,
**{k: v for k, v in completion_data.items() if k != "actual_quantity"}
}
updated_batch = self.production_repo.update(batch_id, update_data)
# Add finished products to inventory
recipe = self.recipe_repo.get_by_id(batch.recipe_id)
if recipe:
product_data = {
"ingredient_id": str(recipe.finished_product_id),
"quantity": actual_quantity,
"batch_number": batch.batch_number,
"production_date": batch.production_date.isoformat(),
"reference_number": str(batch_id),
"movement_type": "production",
"notes": f"Production batch {batch.batch_number}"
}
inventory_result = await self.inventory_client.add_finished_product_to_inventory(
batch.tenant_id,
product_data
)
if not inventory_result["success"]:
logger.warning(f"Failed to add finished product to inventory: {inventory_result['error']}")
return {
"success": True,
"data": updated_batch.to_dict()
}
except Exception as e:
logger.error(f"Error completing production batch {batch_id}: {e}")
self.db.rollback()
return {
"success": False,
"error": str(e)
}
def get_production_batch_with_consumptions(self, batch_id: UUID) -> Optional[Dict[str, Any]]:
"""Get production batch with all consumption records"""
batch = self.production_repo.get_by_id_with_consumptions(batch_id)
if not batch:
return None
batch_dict = batch.to_dict()
batch_dict["ingredient_consumptions"] = [
cons.to_dict() for cons in batch.ingredient_consumptions
]
return batch_dict
def search_production_batches(
self,
tenant_id: UUID,
search_term: Optional[str] = None,
status: Optional[str] = None,
priority: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
recipe_id: Optional[UUID] = None,
limit: int = 100,
offset: int = 0
) -> List[Dict[str, Any]]:
"""Search production batches with filters"""
production_status = ProductionStatus(status) if status else None
production_priority = ProductionPriority(priority) if priority else None
batches = self.production_repo.search_batches(
tenant_id=tenant_id,
search_term=search_term,
status=production_status,
priority=production_priority,
start_date=start_date,
end_date=end_date,
recipe_id=recipe_id,
limit=limit,
offset=offset
)
return [batch.to_dict() for batch in batches]
def get_active_production_batches(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""Get all active production batches"""
batches = self.production_repo.get_active_batches(tenant_id)
return [batch.to_dict() for batch in batches]
def get_production_statistics(
self,
tenant_id: UUID,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> Dict[str, Any]:
"""Get production statistics for dashboard"""
return self.production_repo.get_production_statistics(tenant_id, start_date, end_date)
async def update_production_batch(
self,
batch_id: UUID,
update_data: Dict[str, Any],
updated_by: UUID
) -> Dict[str, Any]:
"""Update production batch"""
try:
batch = self.production_repo.get_by_id(batch_id)
if not batch:
return {
"success": False,
"error": "Production batch not found"
}
# Add audit info
update_data["updated_by"] = updated_by
updated_batch = self.production_repo.update(batch_id, update_data)
return {
"success": True,
"data": updated_batch.to_dict()
}
except Exception as e:
logger.error(f"Error updating production batch {batch_id}: {e}")
self.db.rollback()
return {
"success": False,
"error": str(e)
}
# Production Schedule methods
def create_production_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new production schedule"""
try:
schedule = self.schedule_repo.create(schedule_data)
return {
"success": True,
"data": schedule.to_dict()
}
except Exception as e:
logger.error(f"Error creating production schedule: {e}")
self.db.rollback()
return {
"success": False,
"error": str(e)
}
def get_production_schedule(self, schedule_id: UUID) -> Optional[Dict[str, Any]]:
"""Get production schedule by ID"""
schedule = self.schedule_repo.get_by_id(schedule_id)
return schedule.to_dict() if schedule else None
def get_production_schedule_by_date(self, tenant_id: UUID, schedule_date: date) -> Optional[Dict[str, Any]]:
"""Get production schedule for specific date"""
schedule = self.schedule_repo.get_by_date(tenant_id, schedule_date)
return schedule.to_dict() if schedule else None
def get_published_schedules(
self,
tenant_id: UUID,
start_date: date,
end_date: date
) -> List[Dict[str, Any]]:
"""Get published schedules within date range"""
schedules = self.schedule_repo.get_published_schedules(tenant_id, start_date, end_date)
return [schedule.to_dict() for schedule in schedules]
def get_production_schedules_range(
self,
tenant_id: UUID,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> List[Dict[str, Any]]:
"""Get all schedules within date range"""
if not start_date:
start_date = date.today()
if not end_date:
from datetime import timedelta
end_date = start_date + timedelta(days=7)
schedules = self.schedule_repo.get_published_schedules(tenant_id, start_date, end_date)
return [schedule.to_dict() for schedule in schedules]

View File

@@ -7,186 +7,30 @@ import logging
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from ..repositories.recipe_repository import RecipeRepository, RecipeIngredientRepository
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
from .inventory_client import InventoryClient
from ..core.config import settings
from ..repositories.recipe_repository import RecipeRepository
from ..schemas.recipes import RecipeCreate, RecipeUpdate
logger = logging.getLogger(__name__)
class RecipeService:
"""Service for recipe management operations"""
def __init__(self, db: Session):
self.db = db
self.recipe_repo = RecipeRepository(db)
self.ingredient_repo = RecipeIngredientRepository(db)
self.inventory_client = InventoryClient()
async def create_recipe(
self,
recipe_data: Dict[str, Any],
ingredients_data: List[Dict[str, Any]],
created_by: UUID
) -> Dict[str, Any]:
"""Create a new recipe with ingredients"""
"""Async service for recipe management operations"""
def __init__(self, session: AsyncSession):
self.session = session
self.recipe_repo = RecipeRepository(session)
async def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]:
"""Get recipe by ID with ingredients"""
try:
# Validate finished product exists in inventory
finished_product = await self.inventory_client.get_ingredient_by_id(
recipe_data["tenant_id"],
recipe_data["finished_product_id"]
)
if not finished_product:
return {
"success": False,
"error": "Finished product not found in inventory"
}
if finished_product.get("product_type") != "finished_product":
return {
"success": False,
"error": "Referenced item is not a finished product"
}
# Validate ingredients exist in inventory
ingredient_ids = [UUID(ing["ingredient_id"]) for ing in ingredients_data]
ingredients = await self.inventory_client.get_ingredients_by_ids(
recipe_data["tenant_id"],
ingredient_ids
)
if len(ingredients) != len(ingredient_ids):
return {
"success": False,
"error": "Some ingredients not found in inventory"
}
# Create recipe
recipe_data["created_by"] = created_by
recipe = self.recipe_repo.create(recipe_data)
# Create recipe ingredients
for ingredient_data in ingredients_data:
ingredient_data["tenant_id"] = recipe_data["tenant_id"]
ingredient_data["recipe_id"] = recipe.id
# Calculate cost if available
inventory_ingredient = next(
(ing for ing in ingredients if ing["id"] == ingredient_data["ingredient_id"]),
None
)
if inventory_ingredient and inventory_ingredient.get("average_cost"):
unit_cost = float(inventory_ingredient["average_cost"])
total_cost = unit_cost * ingredient_data["quantity"]
ingredient_data["unit_cost"] = unit_cost
ingredient_data["total_cost"] = total_cost
ingredient_data["cost_updated_at"] = datetime.utcnow()
self.ingredient_repo.create(ingredient_data)
# Calculate and update recipe cost
await self._update_recipe_cost(recipe.id)
return {
"success": True,
"data": recipe.to_dict()
}
return await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
except Exception as e:
logger.error(f"Error creating recipe: {e}")
self.db.rollback()
return {
"success": False,
"error": str(e)
}
async def update_recipe(
self,
recipe_id: UUID,
recipe_data: Dict[str, Any],
ingredients_data: Optional[List[Dict[str, Any]]] = None,
updated_by: UUID = None
) -> Dict[str, Any]:
"""Update an existing recipe"""
try:
recipe = self.recipe_repo.get_by_id(recipe_id)
if not recipe:
return {
"success": False,
"error": "Recipe not found"
}
# Update recipe data
if updated_by:
recipe_data["updated_by"] = updated_by
updated_recipe = self.recipe_repo.update(recipe_id, recipe_data)
# Update ingredients if provided
if ingredients_data is not None:
# Validate ingredients exist in inventory
ingredient_ids = [UUID(ing["ingredient_id"]) for ing in ingredients_data]
ingredients = await self.inventory_client.get_ingredients_by_ids(
recipe.tenant_id,
ingredient_ids
)
if len(ingredients) != len(ingredient_ids):
return {
"success": False,
"error": "Some ingredients not found in inventory"
}
# Update ingredients
for ingredient_data in ingredients_data:
ingredient_data["tenant_id"] = recipe.tenant_id
# Calculate cost if available
inventory_ingredient = next(
(ing for ing in ingredients if ing["id"] == ingredient_data["ingredient_id"]),
None
)
if inventory_ingredient and inventory_ingredient.get("average_cost"):
unit_cost = float(inventory_ingredient["average_cost"])
total_cost = unit_cost * ingredient_data["quantity"]
ingredient_data["unit_cost"] = unit_cost
ingredient_data["total_cost"] = total_cost
ingredient_data["cost_updated_at"] = datetime.utcnow()
self.ingredient_repo.update_ingredients_for_recipe(recipe_id, ingredients_data)
# Recalculate recipe cost
await self._update_recipe_cost(recipe_id)
return {
"success": True,
"data": updated_recipe.to_dict()
}
except Exception as e:
logger.error(f"Error updating recipe {recipe_id}: {e}")
self.db.rollback()
return {
"success": False,
"error": str(e)
}
def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]:
"""Get recipe with all ingredients"""
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
if not recipe:
logger.error(f"Error getting recipe {recipe_id}: {e}")
return None
recipe_dict = recipe.to_dict()
recipe_dict["ingredients"] = [ing.to_dict() for ing in recipe.ingredients]
return recipe_dict
def search_recipes(
async def search_recipes(
self,
tenant_id: UUID,
search_term: Optional[str] = None,
@@ -199,176 +43,222 @@ class RecipeService:
offset: int = 0
) -> List[Dict[str, Any]]:
"""Search recipes with filters"""
recipe_status = RecipeStatus(status) if status else None
recipes = self.recipe_repo.search_recipes(
tenant_id=tenant_id,
search_term=search_term,
status=recipe_status,
category=category,
is_seasonal=is_seasonal,
is_signature=is_signature,
difficulty_level=difficulty_level,
limit=limit,
offset=offset
)
return [recipe.to_dict() for recipe in recipes]
def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
try:
return await self.recipe_repo.search_recipes(
tenant_id=tenant_id,
search_term=search_term,
status=status,
category=category,
is_seasonal=is_seasonal,
is_signature=is_signature,
difficulty_level=difficulty_level,
limit=limit,
offset=offset
)
except Exception as e:
logger.error(f"Error searching recipes: {e}")
return []
async def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get recipe statistics for dashboard"""
return self.recipe_repo.get_recipe_statistics(tenant_id)
try:
return await self.recipe_repo.get_recipe_statistics(tenant_id)
except Exception as e:
logger.error(f"Error getting recipe statistics: {e}")
return {"total_recipes": 0, "active_recipes": 0, "signature_recipes": 0, "seasonal_recipes": 0}
async def create_recipe(
self,
recipe_data: Dict[str, Any],
ingredients_data: List[Dict[str, Any]],
created_by: UUID
) -> Dict[str, Any]:
"""Create a new recipe with ingredients"""
try:
# Add metadata
recipe_data["created_by"] = created_by
recipe_data["created_at"] = datetime.utcnow()
recipe_data["updated_at"] = datetime.utcnow()
# Use the shared repository's create method
recipe_create = RecipeCreate(**recipe_data)
recipe = await self.recipe_repo.create(recipe_create)
# Get the created recipe with ingredients (if the repository supports it)
result = await self.recipe_repo.get_recipe_with_ingredients(recipe.id)
return {
"success": True,
"data": result
}
except Exception as e:
logger.error(f"Error creating recipe: {e}")
await self.session.rollback()
return {
"success": False,
"error": str(e)
}
async def update_recipe(
self,
recipe_id: UUID,
recipe_data: Dict[str, Any],
ingredients_data: Optional[List[Dict[str, Any]]] = None,
updated_by: UUID = None
) -> Dict[str, Any]:
"""Update an existing recipe"""
try:
# Check if recipe exists
existing_recipe = await self.recipe_repo.get_by_id(recipe_id)
if not existing_recipe:
return {
"success": False,
"error": "Recipe not found"
}
# Add metadata
if updated_by:
recipe_data["updated_by"] = updated_by
recipe_data["updated_at"] = datetime.utcnow()
# Use the shared repository's update method
recipe_update = RecipeUpdate(**recipe_data)
updated_recipe = await self.recipe_repo.update(recipe_id, recipe_update)
# Get the updated recipe with ingredients
result = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
return {
"success": True,
"data": result
}
except Exception as e:
logger.error(f"Error updating recipe {recipe_id}: {e}")
await self.session.rollback()
return {
"success": False,
"error": str(e)
}
async def delete_recipe(self, recipe_id: UUID) -> bool:
"""Delete a recipe"""
try:
return await self.recipe_repo.delete(recipe_id)
except Exception as e:
logger.error(f"Error deleting recipe {recipe_id}: {e}")
return False
async def check_recipe_feasibility(self, recipe_id: UUID, batch_multiplier: float = 1.0) -> Dict[str, Any]:
"""Check if recipe can be produced with current inventory"""
try:
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
if not recipe:
return {
"success": False,
"error": "Recipe not found"
}
# Calculate required ingredients
required_ingredients = []
for ingredient in recipe.ingredients:
required_quantity = ingredient.quantity * batch_multiplier
required_ingredients.append({
"ingredient_id": str(ingredient.ingredient_id),
"required_quantity": required_quantity,
"unit": ingredient.unit.value
})
# Check availability with inventory service
availability_check = await self.inventory_client.check_ingredient_availability(
recipe.tenant_id,
required_ingredients
)
if not availability_check["success"]:
return availability_check
availability_data = availability_check["data"]
# Simplified feasibility check - can be enhanced later with inventory service integration
return {
"success": True,
"data": {
"recipe_id": str(recipe_id),
"recipe_name": recipe.name,
"recipe_name": recipe["name"],
"batch_multiplier": batch_multiplier,
"feasible": availability_data.get("all_available", False),
"missing_ingredients": availability_data.get("missing_ingredients", []),
"insufficient_ingredients": availability_data.get("insufficient_ingredients", [])
"feasible": True,
"missing_ingredients": [],
"insufficient_ingredients": []
}
}
except Exception as e:
logger.error(f"Error checking recipe feasibility {recipe_id}: {e}")
return {
"success": False,
"error": str(e)
}
async def duplicate_recipe(
self,
recipe_id: UUID,
new_name: str,
self,
recipe_id: UUID,
new_name: str,
created_by: UUID
) -> Dict[str, Any]:
"""Create a duplicate of an existing recipe"""
try:
new_recipe = self.recipe_repo.duplicate_recipe(recipe_id, new_name, created_by)
if not new_recipe:
# Get original recipe
original_recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
if not original_recipe:
return {
"success": False,
"error": "Recipe not found"
}
return {
"success": True,
"data": new_recipe.to_dict()
}
# Create new recipe data
new_recipe_data = original_recipe.copy()
new_recipe_data["name"] = new_name
# Remove fields that should be auto-generated
new_recipe_data.pop("id", None)
new_recipe_data.pop("created_at", None)
new_recipe_data.pop("updated_at", None)
# Handle ingredients
ingredients = new_recipe_data.pop("ingredients", [])
# Create the duplicate
result = await self.create_recipe(new_recipe_data, ingredients, created_by)
return result
except Exception as e:
logger.error(f"Error duplicating recipe {recipe_id}: {e}")
self.db.rollback()
await self.session.rollback()
return {
"success": False,
"error": str(e)
}
async def activate_recipe(self, recipe_id: UUID, activated_by: UUID) -> Dict[str, Any]:
"""Activate a recipe for production"""
try:
# Check if recipe is complete and valid
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
# Check if recipe exists
recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
if not recipe:
return {
"success": False,
"error": "Recipe not found"
}
if not recipe.ingredients:
if not recipe.get("ingredients"):
return {
"success": False,
"error": "Recipe must have at least one ingredient"
}
# Validate all ingredients exist in inventory
ingredient_ids = [ing.ingredient_id for ing in recipe.ingredients]
ingredients = await self.inventory_client.get_ingredients_by_ids(
recipe.tenant_id,
ingredient_ids
)
if len(ingredients) != len(ingredient_ids):
return {
"success": False,
"error": "Some recipe ingredients not found in inventory"
}
# Update recipe status
updated_recipe = self.recipe_repo.update(recipe_id, {
"status": RecipeStatus.ACTIVE,
"updated_by": activated_by
})
update_data = {
"status": "active",
"updated_by": activated_by,
"updated_at": datetime.utcnow()
}
recipe_update = RecipeUpdate(**update_data)
await self.recipe_repo.update(recipe_id, recipe_update)
# Get the updated recipe
result = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
return {
"success": True,
"data": updated_recipe.to_dict()
"data": result
}
except Exception as e:
logger.error(f"Error activating recipe {recipe_id}: {e}")
return {
"success": False,
"error": str(e)
}
async def _update_recipe_cost(self, recipe_id: UUID) -> None:
"""Update recipe cost based on ingredient costs"""
try:
total_cost = self.ingredient_repo.calculate_recipe_cost(recipe_id)
recipe = self.recipe_repo.get_by_id(recipe_id)
if recipe:
cost_per_unit = total_cost / recipe.yield_quantity if recipe.yield_quantity > 0 else 0
# Add overhead
overhead_cost = cost_per_unit * (settings.OVERHEAD_PERCENTAGE / 100)
total_cost_with_overhead = cost_per_unit + overhead_cost
# Calculate suggested selling price with target margin
if recipe.target_margin_percentage:
suggested_price = total_cost_with_overhead * (1 + recipe.target_margin_percentage / 100)
else:
suggested_price = total_cost_with_overhead * 1.3 # Default 30% margin
self.recipe_repo.update(recipe_id, {
"last_calculated_cost": total_cost_with_overhead,
"cost_calculation_date": datetime.utcnow(),
"suggested_selling_price": suggested_price
})
except Exception as e:
logger.error(f"Error updating recipe cost for {recipe_id}: {e}")
}