Create new services: inventory, recipes, suppliers
This commit is contained in:
1
services/recipes/app/__init__.py
Normal file
1
services/recipes/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/recipes/app/__init__.py
|
||||
1
services/recipes/app/api/__init__.py
Normal file
1
services/recipes/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/recipes/app/api/__init__.py
|
||||
117
services/recipes/app/api/ingredients.py
Normal file
117
services/recipes/app/api/ingredients.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# services/recipes/app/api/ingredients.py
|
||||
"""
|
||||
API endpoints for ingredient-related operations (bridge to inventory service)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
from ..services.inventory_client import InventoryClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
|
||||
"""Extract tenant ID from header"""
|
||||
try:
|
||||
return UUID(x_tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_ingredients(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
search_term: Optional[str] = Query(None),
|
||||
product_type: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Search ingredients from inventory service"""
|
||||
try:
|
||||
inventory_client = InventoryClient()
|
||||
|
||||
# This would call the inventory service search endpoint
|
||||
# For now, return a placeholder response
|
||||
return {
|
||||
"ingredients": [],
|
||||
"total": 0,
|
||||
"message": "Integration with inventory service needed"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching ingredients: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/{ingredient_id}")
|
||||
async def get_ingredient(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id)
|
||||
):
|
||||
"""Get ingredient details from inventory service"""
|
||||
try:
|
||||
inventory_client = InventoryClient()
|
||||
ingredient = await inventory_client.get_ingredient_by_id(tenant_id, ingredient_id)
|
||||
|
||||
if not ingredient:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
|
||||
return ingredient
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ingredient {ingredient_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/{ingredient_id}/stock")
|
||||
async def get_ingredient_stock(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id)
|
||||
):
|
||||
"""Get ingredient stock level from inventory service"""
|
||||
try:
|
||||
inventory_client = InventoryClient()
|
||||
stock = await inventory_client.get_ingredient_stock_level(tenant_id, ingredient_id)
|
||||
|
||||
if not stock:
|
||||
raise HTTPException(status_code=404, detail="Stock information not found")
|
||||
|
||||
return stock
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stock for ingredient {ingredient_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/check-availability")
|
||||
async def check_ingredients_availability(
|
||||
required_ingredients: List[dict],
|
||||
tenant_id: UUID = Depends(get_tenant_id)
|
||||
):
|
||||
"""Check if required ingredients are available for production"""
|
||||
try:
|
||||
inventory_client = InventoryClient()
|
||||
result = await inventory_client.check_ingredient_availability(
|
||||
tenant_id,
|
||||
required_ingredients
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result["data"]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking ingredient availability: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
427
services/recipes/app/api/production.py
Normal file
427
services/recipes/app/api/production.py
Normal file
@@ -0,0 +1,427 @@
|
||||
# services/recipes/app/api/production.py
|
||||
"""
|
||||
API endpoints for production management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from datetime import date, datetime
|
||||
import logging
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..services.production_service import ProductionService
|
||||
from ..schemas.production import (
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
ProductionBatchResponse,
|
||||
ProductionBatchSearchRequest,
|
||||
ProductionScheduleCreate,
|
||||
ProductionScheduleUpdate,
|
||||
ProductionScheduleResponse,
|
||||
ProductionStatisticsResponse,
|
||||
StartProductionRequest,
|
||||
CompleteProductionRequest
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
|
||||
"""Extract tenant ID from header"""
|
||||
try:
|
||||
return UUID(x_tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
|
||||
|
||||
|
||||
def get_user_id(x_user_id: str = Header(...)) -> UUID:
|
||||
"""Extract user ID from header"""
|
||||
try:
|
||||
return UUID(x_user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
|
||||
# Production Batch Endpoints
|
||||
|
||||
@router.post("/batches", response_model=ProductionBatchResponse)
|
||||
async def create_production_batch(
|
||||
batch_data: ProductionBatchCreate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new production batch"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
batch_dict = batch_data.dict()
|
||||
batch_dict["tenant_id"] = tenant_id
|
||||
|
||||
result = await production_service.create_production_batch(batch_dict, user_id)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionBatchResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production batch: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}", response_model=ProductionBatchResponse)
|
||||
async def get_production_batch(
|
||||
batch_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production batch by ID with consumptions"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
# Verify tenant ownership
|
||||
if batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return ProductionBatchResponse(**batch)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/batches/{batch_id}", response_model=ProductionBatchResponse)
|
||||
async def update_production_batch(
|
||||
batch_id: UUID,
|
||||
batch_data: ProductionBatchUpdate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an existing production batch"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
# Check if batch exists and belongs to tenant
|
||||
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
if not existing_batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
if existing_batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
batch_dict = batch_data.dict(exclude_unset=True)
|
||||
|
||||
result = await production_service.update_production_batch(batch_id, batch_dict, user_id)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionBatchResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/batches/{batch_id}")
|
||||
async def delete_production_batch(
|
||||
batch_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a production batch"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
# Check if batch exists and belongs to tenant
|
||||
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
if not existing_batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
if existing_batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
success = production_service.production_repo.delete(batch_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
return {"message": "Production batch deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/batches", response_model=List[ProductionBatchResponse])
|
||||
async def search_production_batches(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
search_term: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
priority: Optional[str] = Query(None),
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
recipe_id: Optional[UUID] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Search production batches with filters"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
batches = production_service.search_production_batches(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=status,
|
||||
priority=priority,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
recipe_id=recipe_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return [ProductionBatchResponse(**batch) for batch in batches]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching production batches: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/start", response_model=ProductionBatchResponse)
|
||||
async def start_production_batch(
|
||||
batch_id: UUID,
|
||||
start_data: StartProductionRequest,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Start production batch and record ingredient consumptions"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
# Check if batch exists and belongs to tenant
|
||||
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
if not existing_batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
if existing_batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
consumptions_list = [cons.dict() for cons in start_data.ingredient_consumptions]
|
||||
|
||||
result = await production_service.start_production_batch(
|
||||
batch_id,
|
||||
consumptions_list,
|
||||
start_data.staff_member or user_id,
|
||||
start_data.production_notes
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionBatchResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/complete", response_model=ProductionBatchResponse)
|
||||
async def complete_production_batch(
|
||||
batch_id: UUID,
|
||||
complete_data: CompleteProductionRequest,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Complete production batch and add finished products to inventory"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
# Check if batch exists and belongs to tenant
|
||||
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
if not existing_batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
if existing_batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
completion_data = complete_data.dict()
|
||||
|
||||
result = await production_service.complete_production_batch(
|
||||
batch_id,
|
||||
completion_data,
|
||||
complete_data.staff_member or user_id
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionBatchResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error completing production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/batches/active/list", response_model=List[ProductionBatchResponse])
|
||||
async def get_active_production_batches(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all active production batches"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
batches = production_service.get_active_production_batches(tenant_id)
|
||||
|
||||
return [ProductionBatchResponse(**batch) for batch in batches]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting active production batches: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/statistics/dashboard", response_model=ProductionStatisticsResponse)
|
||||
async def get_production_statistics(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production statistics for dashboard"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
stats = production_service.get_production_statistics(tenant_id, start_date, end_date)
|
||||
|
||||
return ProductionStatisticsResponse(**stats)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production statistics: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# Production Schedule Endpoints
|
||||
|
||||
@router.post("/schedules", response_model=ProductionScheduleResponse)
|
||||
async def create_production_schedule(
|
||||
schedule_data: ProductionScheduleCreate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new production schedule"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
schedule_dict = schedule_data.dict()
|
||||
schedule_dict["tenant_id"] = tenant_id
|
||||
schedule_dict["created_by"] = user_id
|
||||
|
||||
result = production_service.create_production_schedule(schedule_dict)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionScheduleResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production schedule: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/schedules/{schedule_id}", response_model=ProductionScheduleResponse)
|
||||
async def get_production_schedule(
|
||||
schedule_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production schedule by ID"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
schedule = production_service.get_production_schedule(schedule_id)
|
||||
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="Production schedule not found")
|
||||
|
||||
# Verify tenant ownership
|
||||
if schedule["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return ProductionScheduleResponse(**schedule)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production schedule {schedule_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/schedules/date/{schedule_date}", response_model=ProductionScheduleResponse)
|
||||
async def get_production_schedule_by_date(
|
||||
schedule_date: date,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production schedule for specific date"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
schedule = production_service.get_production_schedule_by_date(tenant_id, schedule_date)
|
||||
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="Production schedule not found for this date")
|
||||
|
||||
return ProductionScheduleResponse(**schedule)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production schedule for {schedule_date}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/schedules", response_model=List[ProductionScheduleResponse])
|
||||
async def get_production_schedules(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
published_only: bool = Query(False),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production schedules within date range"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
if published_only:
|
||||
schedules = production_service.get_published_schedules(tenant_id, start_date, end_date)
|
||||
else:
|
||||
schedules = production_service.get_production_schedules_range(tenant_id, start_date, end_date)
|
||||
|
||||
return [ProductionScheduleResponse(**schedule) for schedule in schedules]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production schedules: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
359
services/recipes/app/api/recipes.py
Normal file
359
services/recipes/app/api/recipes.py
Normal file
@@ -0,0 +1,359 @@
|
||||
# services/recipes/app/api/recipes.py
|
||||
"""
|
||||
API endpoints for recipe management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..services.recipe_service import RecipeService
|
||||
from ..schemas.recipes import (
|
||||
RecipeCreate,
|
||||
RecipeUpdate,
|
||||
RecipeResponse,
|
||||
RecipeSearchRequest,
|
||||
RecipeDuplicateRequest,
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
|
||||
"""Extract tenant ID from header"""
|
||||
try:
|
||||
return UUID(x_tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
|
||||
|
||||
|
||||
def get_user_id(x_user_id: str = Header(...)) -> UUID:
|
||||
"""Extract user ID from header"""
|
||||
try:
|
||||
return UUID(x_user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
|
||||
@router.post("/", response_model=RecipeResponse)
|
||||
async def create_recipe(
|
||||
recipe_data: RecipeCreate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Convert Pydantic model to dict
|
||||
recipe_dict = recipe_data.dict(exclude={"ingredients"})
|
||||
recipe_dict["tenant_id"] = tenant_id
|
||||
|
||||
ingredients_list = [ing.dict() for ing in recipe_data.ingredients]
|
||||
|
||||
result = await recipe_service.create_recipe(
|
||||
recipe_dict,
|
||||
ingredients_list,
|
||||
user_id
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return RecipeResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating recipe: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/{recipe_id}", response_model=RecipeResponse)
|
||||
async def get_recipe(
|
||||
recipe_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get recipe by ID with ingredients"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
# Verify tenant ownership
|
||||
if recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return RecipeResponse(**recipe)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recipe {recipe_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/{recipe_id}", response_model=RecipeResponse)
|
||||
async def update_recipe(
|
||||
recipe_id: UUID,
|
||||
recipe_data: RecipeUpdate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an existing recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Convert Pydantic model to dict
|
||||
recipe_dict = recipe_data.dict(exclude={"ingredients"}, exclude_unset=True)
|
||||
|
||||
ingredients_list = None
|
||||
if recipe_data.ingredients is not None:
|
||||
ingredients_list = [ing.dict() for ing in recipe_data.ingredients]
|
||||
|
||||
result = await recipe_service.update_recipe(
|
||||
recipe_id,
|
||||
recipe_dict,
|
||||
ingredients_list,
|
||||
user_id
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return RecipeResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe {recipe_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}")
|
||||
async def delete_recipe(
|
||||
recipe_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Use repository to delete
|
||||
success = recipe_service.recipe_repo.delete(recipe_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
return {"message": "Recipe deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting recipe {recipe_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/", response_model=List[RecipeResponse])
|
||||
async def search_recipes(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
search_term: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
is_seasonal: Optional[bool] = Query(None),
|
||||
is_signature: Optional[bool] = Query(None),
|
||||
difficulty_level: Optional[int] = Query(None, ge=1, le=5),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Search recipes with filters"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
recipes = recipe_service.search_recipes(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=status,
|
||||
category=category,
|
||||
is_seasonal=is_seasonal,
|
||||
is_signature=is_signature,
|
||||
difficulty_level=difficulty_level,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return [RecipeResponse(**recipe) for recipe in recipes]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching recipes: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse)
|
||||
async def duplicate_recipe(
|
||||
recipe_id: UUID,
|
||||
duplicate_data: RecipeDuplicateRequest,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a duplicate of an existing recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if original recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
result = await recipe_service.duplicate_recipe(
|
||||
recipe_id,
|
||||
duplicate_data.new_name,
|
||||
user_id
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return RecipeResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error duplicating recipe {recipe_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/activate", response_model=RecipeResponse)
|
||||
async def activate_recipe(
|
||||
recipe_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Activate a recipe for production"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
result = await recipe_service.activate_recipe(recipe_id, user_id)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return RecipeResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error activating recipe {recipe_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/{recipe_id}/feasibility", response_model=RecipeFeasibilityResponse)
|
||||
async def check_recipe_feasibility(
|
||||
recipe_id: UUID,
|
||||
batch_multiplier: float = Query(1.0, gt=0),
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if recipe can be produced with current inventory"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
result = await recipe_service.check_recipe_feasibility(recipe_id, batch_multiplier)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return RecipeFeasibilityResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking recipe feasibility {recipe_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/statistics/dashboard", response_model=RecipeStatisticsResponse)
|
||||
async def get_recipe_statistics(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get recipe statistics for dashboard"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
stats = recipe_service.get_recipe_statistics(tenant_id)
|
||||
|
||||
return RecipeStatisticsResponse(**stats)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recipe statistics: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/categories/list")
|
||||
async def get_recipe_categories(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of recipe categories used by tenant"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Get categories from existing recipes
|
||||
recipes = recipe_service.search_recipes(tenant_id, limit=1000)
|
||||
categories = list(set(recipe["category"] for recipe in recipes if recipe["category"]))
|
||||
categories.sort()
|
||||
|
||||
return {"categories": categories}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recipe categories: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
1
services/recipes/app/core/__init__.py
Normal file
1
services/recipes/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/recipes/app/core/__init__.py
|
||||
82
services/recipes/app/core/config.py
Normal file
82
services/recipes/app/core/config.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# services/recipes/app/core/config.py
|
||||
"""
|
||||
Configuration management for Recipe Service
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Recipe service configuration settings"""
|
||||
|
||||
# Service identification
|
||||
SERVICE_NAME: str = "recipes"
|
||||
SERVICE_VERSION: str = "1.0.0"
|
||||
|
||||
# API settings
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = os.getenv(
|
||||
"RECIPES_DATABASE_URL",
|
||||
"postgresql://recipes_user:recipes_pass@localhost:5432/recipes_db"
|
||||
)
|
||||
|
||||
# Redis (if needed for caching)
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
# External service URLs
|
||||
INVENTORY_SERVICE_URL: str = os.getenv(
|
||||
"INVENTORY_SERVICE_URL",
|
||||
"http://inventory:8000"
|
||||
)
|
||||
SALES_SERVICE_URL: str = os.getenv(
|
||||
"SALES_SERVICE_URL",
|
||||
"http://sales:8000"
|
||||
)
|
||||
|
||||
# Authentication
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# Production configuration
|
||||
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
|
||||
|
||||
# CORS settings
|
||||
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",")
|
||||
|
||||
# Recipe-specific settings
|
||||
MAX_RECIPE_INGREDIENTS: int = int(os.getenv("MAX_RECIPE_INGREDIENTS", "50"))
|
||||
MAX_BATCH_SIZE_MULTIPLIER: float = float(os.getenv("MAX_BATCH_SIZE_MULTIPLIER", "10.0"))
|
||||
DEFAULT_RECIPE_VERSION: str = "1.0"
|
||||
|
||||
# Production settings
|
||||
MAX_PRODUCTION_BATCHES_PER_DAY: int = int(os.getenv("MAX_PRODUCTION_BATCHES_PER_DAY", "100"))
|
||||
PRODUCTION_SCHEDULE_DAYS_AHEAD: int = int(os.getenv("PRODUCTION_SCHEDULE_DAYS_AHEAD", "7"))
|
||||
|
||||
# Cost calculation settings
|
||||
OVERHEAD_PERCENTAGE: float = float(os.getenv("OVERHEAD_PERCENTAGE", "15.0")) # Default 15% overhead
|
||||
LABOR_COST_PER_HOUR: float = float(os.getenv("LABOR_COST_PER_HOUR", "25.0")) # Default €25/hour
|
||||
|
||||
# Quality control
|
||||
MIN_QUALITY_SCORE: float = float(os.getenv("MIN_QUALITY_SCORE", "6.0")) # Minimum acceptable quality score
|
||||
MAX_DEFECT_RATE: float = float(os.getenv("MAX_DEFECT_RATE", "5.0")) # Maximum 5% defect rate
|
||||
|
||||
# Messaging/Events (if using message queues)
|
||||
RABBITMQ_URL: Optional[str] = os.getenv("RABBITMQ_URL")
|
||||
KAFKA_BOOTSTRAP_SERVERS: Optional[str] = os.getenv("KAFKA_BOOTSTRAP_SERVERS")
|
||||
|
||||
# Health check settings
|
||||
HEALTH_CHECK_TIMEOUT: int = int(os.getenv("HEALTH_CHECK_TIMEOUT", "30"))
|
||||
|
||||
class Config:
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
77
services/recipes/app/core/database.py
Normal file
77
services/recipes/app/core/database.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# services/recipes/app/core/database.py
|
||||
"""
|
||||
Database configuration and session management for Recipe Service
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
# Create database engine
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
poolclass=StaticPool,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300,
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
Dependency to get database session
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_context() -> Generator[Session, None, None]:
|
||||
"""
|
||||
Context manager for database session
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
"""Database management utilities"""
|
||||
|
||||
@staticmethod
|
||||
def create_all_tables():
|
||||
"""Create all database tables"""
|
||||
from shared.database.base import Base
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@staticmethod
|
||||
def drop_all_tables():
|
||||
"""Drop all database tables (for testing)"""
|
||||
from shared.database.base import Base
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@staticmethod
|
||||
def get_session() -> Session:
|
||||
"""Get a new database session"""
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
# Database manager instance
|
||||
db_manager = DatabaseManager()
|
||||
161
services/recipes/app/main.py
Normal file
161
services/recipes/app/main.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# services/recipes/app/main.py
|
||||
"""
|
||||
Recipe Service - FastAPI application
|
||||
Handles recipe management, production planning, and inventory consumption tracking
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import time
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from .core.config import settings
|
||||
from .core.database import db_manager
|
||||
from .api import recipes, production, ingredients
|
||||
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan events"""
|
||||
# Startup
|
||||
logger.info(f"Starting {settings.SERVICE_NAME} service v{settings.SERVICE_VERSION}")
|
||||
|
||||
# Create database tables
|
||||
try:
|
||||
db_manager.create_all_tables()
|
||||
logger.info("Database tables created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create database tables: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info(f"Shutting down {settings.SERVICE_NAME} service")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Recipe Management Service",
|
||||
description="Comprehensive recipe management, production planning, and inventory consumption tracking for bakery operations",
|
||||
version=settings.SERVICE_VERSION,
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
)
|
||||
|
||||
# Add middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
|
||||
# Request timing middleware
|
||||
@app.middleware("http")
|
||||
async def add_process_time_header(request: Request, call_next):
|
||||
"""Add processing time header to responses"""
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
|
||||
|
||||
# Global exception handler
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Global exception handler"""
|
||||
logger.error(f"Global exception on {request.url}: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "Internal server error",
|
||||
"error": str(exc) if settings.DEBUG else "An unexpected error occurred"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
try:
|
||||
# Test database connection
|
||||
with db_manager.get_session() as db:
|
||||
db.execute("SELECT 1")
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.SERVICE_VERSION,
|
||||
"environment": settings.ENVIRONMENT
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "unhealthy",
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.SERVICE_VERSION,
|
||||
"error": str(e)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Include API routers
|
||||
app.include_router(
|
||||
recipes.router,
|
||||
prefix=f"{settings.API_V1_PREFIX}/recipes",
|
||||
tags=["recipes"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
production.router,
|
||||
prefix=f"{settings.API_V1_PREFIX}/production",
|
||||
tags=["production"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
ingredients.router,
|
||||
prefix=f"{settings.API_V1_PREFIX}/ingredients",
|
||||
tags=["ingredients"]
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.SERVICE_VERSION,
|
||||
"status": "running",
|
||||
"docs_url": "/docs" if settings.DEBUG else None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.DEBUG,
|
||||
log_level=settings.LOG_LEVEL.lower()
|
||||
)
|
||||
25
services/recipes/app/models/__init__.py
Normal file
25
services/recipes/app/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# services/recipes/app/models/__init__.py
|
||||
|
||||
from .recipes import (
|
||||
Recipe,
|
||||
RecipeIngredient,
|
||||
ProductionBatch,
|
||||
ProductionIngredientConsumption,
|
||||
ProductionSchedule,
|
||||
RecipeStatus,
|
||||
ProductionStatus,
|
||||
MeasurementUnit,
|
||||
ProductionPriority
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Recipe",
|
||||
"RecipeIngredient",
|
||||
"ProductionBatch",
|
||||
"ProductionIngredientConsumption",
|
||||
"ProductionSchedule",
|
||||
"RecipeStatus",
|
||||
"ProductionStatus",
|
||||
"MeasurementUnit",
|
||||
"ProductionPriority"
|
||||
]
|
||||
535
services/recipes/app/models/recipes.py
Normal file
535
services/recipes/app/models/recipes.py
Normal file
@@ -0,0 +1,535 @@
|
||||
# services/recipes/app/models/recipes.py
|
||||
"""
|
||||
Recipe and Production Management models for Recipe Service
|
||||
Comprehensive recipe management, production tracking, and inventory consumption
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric, ForeignKey, Enum as SQLEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from shared.database.base import Base
|
||||
|
||||
|
||||
class RecipeStatus(enum.Enum):
|
||||
"""Recipe lifecycle status"""
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
TESTING = "testing"
|
||||
ARCHIVED = "archived"
|
||||
DISCONTINUED = "discontinued"
|
||||
|
||||
|
||||
class ProductionStatus(enum.Enum):
|
||||
"""Production batch status"""
|
||||
PLANNED = "planned"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class MeasurementUnit(enum.Enum):
|
||||
"""Units for recipe measurements"""
|
||||
GRAMS = "g"
|
||||
KILOGRAMS = "kg"
|
||||
MILLILITERS = "ml"
|
||||
LITERS = "l"
|
||||
CUPS = "cups"
|
||||
TABLESPOONS = "tbsp"
|
||||
TEASPOONS = "tsp"
|
||||
UNITS = "units"
|
||||
PIECES = "pieces"
|
||||
PERCENTAGE = "%"
|
||||
|
||||
|
||||
class ProductionPriority(enum.Enum):
|
||||
"""Production batch priority levels"""
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class Recipe(Base):
|
||||
"""Master recipe definitions"""
|
||||
__tablename__ = "recipes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Recipe identification
|
||||
name = Column(String(255), nullable=False, index=True)
|
||||
recipe_code = Column(String(100), nullable=True, index=True)
|
||||
version = Column(String(20), nullable=False, default="1.0")
|
||||
|
||||
# Product association
|
||||
finished_product_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Links to inventory ingredient with product_type=finished_product
|
||||
|
||||
# Recipe details
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=True, index=True) # bread, pastries, cakes, etc.
|
||||
cuisine_type = Column(String(100), nullable=True)
|
||||
difficulty_level = Column(Integer, nullable=False, default=1) # 1-5 scale
|
||||
|
||||
# Production metrics
|
||||
yield_quantity = Column(Float, nullable=False) # How many units this recipe produces
|
||||
yield_unit = Column(SQLEnum(MeasurementUnit), nullable=False)
|
||||
prep_time_minutes = Column(Integer, nullable=True)
|
||||
cook_time_minutes = Column(Integer, nullable=True)
|
||||
total_time_minutes = Column(Integer, nullable=True)
|
||||
rest_time_minutes = Column(Integer, nullable=True) # Rising time, cooling time, etc.
|
||||
|
||||
# Cost and pricing
|
||||
estimated_cost_per_unit = Column(Numeric(10, 2), nullable=True)
|
||||
last_calculated_cost = Column(Numeric(10, 2), nullable=True)
|
||||
cost_calculation_date = Column(DateTime(timezone=True), nullable=True)
|
||||
target_margin_percentage = Column(Float, nullable=True)
|
||||
suggested_selling_price = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Instructions and notes
|
||||
instructions = Column(JSONB, nullable=True) # Structured step-by-step instructions
|
||||
preparation_notes = Column(Text, nullable=True)
|
||||
storage_instructions = Column(Text, nullable=True)
|
||||
quality_standards = Column(Text, nullable=True)
|
||||
|
||||
# Recipe metadata
|
||||
serves_count = Column(Integer, nullable=True) # How many people/portions
|
||||
nutritional_info = Column(JSONB, nullable=True) # Calories, protein, etc.
|
||||
allergen_info = Column(JSONB, nullable=True) # List of allergens
|
||||
dietary_tags = Column(JSONB, nullable=True) # vegan, gluten-free, etc.
|
||||
|
||||
# Production settings
|
||||
batch_size_multiplier = Column(Float, nullable=False, default=1.0) # Standard batch multiplier
|
||||
minimum_batch_size = Column(Float, nullable=True)
|
||||
maximum_batch_size = Column(Float, nullable=True)
|
||||
optimal_production_temperature = Column(Float, nullable=True) # Celsius
|
||||
optimal_humidity = Column(Float, nullable=True) # Percentage
|
||||
|
||||
# Quality control
|
||||
quality_check_points = Column(JSONB, nullable=True) # Key checkpoints during production
|
||||
common_issues = Column(JSONB, nullable=True) # Known issues and solutions
|
||||
|
||||
# Status and lifecycle
|
||||
status = Column(SQLEnum(RecipeStatus), nullable=False, default=RecipeStatus.DRAFT, index=True)
|
||||
is_seasonal = Column(Boolean, default=False)
|
||||
season_start_month = Column(Integer, nullable=True) # 1-12
|
||||
season_end_month = Column(Integer, nullable=True) # 1-12
|
||||
is_signature_item = Column(Boolean, default=False)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
updated_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
ingredients = relationship("RecipeIngredient", back_populates="recipe", cascade="all, delete-orphan")
|
||||
production_batches = relationship("ProductionBatch", back_populates="recipe", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_recipes_tenant_name', 'tenant_id', 'name'),
|
||||
Index('idx_recipes_tenant_product', 'tenant_id', 'finished_product_id'),
|
||||
Index('idx_recipes_status', 'tenant_id', 'status'),
|
||||
Index('idx_recipes_category', 'tenant_id', 'category', 'status'),
|
||||
Index('idx_recipes_seasonal', 'tenant_id', 'is_seasonal', 'season_start_month', 'season_end_month'),
|
||||
Index('idx_recipes_signature', 'tenant_id', 'is_signature_item', 'status'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'name': self.name,
|
||||
'recipe_code': self.recipe_code,
|
||||
'version': self.version,
|
||||
'finished_product_id': str(self.finished_product_id),
|
||||
'description': self.description,
|
||||
'category': self.category,
|
||||
'cuisine_type': self.cuisine_type,
|
||||
'difficulty_level': self.difficulty_level,
|
||||
'yield_quantity': self.yield_quantity,
|
||||
'yield_unit': self.yield_unit.value if self.yield_unit else None,
|
||||
'prep_time_minutes': self.prep_time_minutes,
|
||||
'cook_time_minutes': self.cook_time_minutes,
|
||||
'total_time_minutes': self.total_time_minutes,
|
||||
'rest_time_minutes': self.rest_time_minutes,
|
||||
'estimated_cost_per_unit': float(self.estimated_cost_per_unit) if self.estimated_cost_per_unit else None,
|
||||
'last_calculated_cost': float(self.last_calculated_cost) if self.last_calculated_cost else None,
|
||||
'cost_calculation_date': self.cost_calculation_date.isoformat() if self.cost_calculation_date else None,
|
||||
'target_margin_percentage': self.target_margin_percentage,
|
||||
'suggested_selling_price': float(self.suggested_selling_price) if self.suggested_selling_price else None,
|
||||
'instructions': self.instructions,
|
||||
'preparation_notes': self.preparation_notes,
|
||||
'storage_instructions': self.storage_instructions,
|
||||
'quality_standards': self.quality_standards,
|
||||
'serves_count': self.serves_count,
|
||||
'nutritional_info': self.nutritional_info,
|
||||
'allergen_info': self.allergen_info,
|
||||
'dietary_tags': self.dietary_tags,
|
||||
'batch_size_multiplier': self.batch_size_multiplier,
|
||||
'minimum_batch_size': self.minimum_batch_size,
|
||||
'maximum_batch_size': self.maximum_batch_size,
|
||||
'optimal_production_temperature': self.optimal_production_temperature,
|
||||
'optimal_humidity': self.optimal_humidity,
|
||||
'quality_check_points': self.quality_check_points,
|
||||
'common_issues': self.common_issues,
|
||||
'status': self.status.value if self.status else None,
|
||||
'is_seasonal': self.is_seasonal,
|
||||
'season_start_month': self.season_start_month,
|
||||
'season_end_month': self.season_end_month,
|
||||
'is_signature_item': self.is_signature_item,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'created_by': str(self.created_by) if self.created_by else None,
|
||||
'updated_by': str(self.updated_by) if self.updated_by else None,
|
||||
}
|
||||
|
||||
|
||||
class RecipeIngredient(Base):
|
||||
"""Ingredients required for each recipe"""
|
||||
__tablename__ = "recipe_ingredients"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
recipe_id = Column(UUID(as_uuid=True), ForeignKey('recipes.id'), nullable=False, index=True)
|
||||
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Links to inventory ingredients
|
||||
|
||||
# Quantity specifications
|
||||
quantity = Column(Float, nullable=False)
|
||||
unit = Column(SQLEnum(MeasurementUnit), nullable=False)
|
||||
quantity_in_base_unit = Column(Float, nullable=True) # Converted to ingredient's base unit
|
||||
|
||||
# Alternative measurements
|
||||
alternative_quantity = Column(Float, nullable=True) # e.g., "2 cups" vs "240ml"
|
||||
alternative_unit = Column(SQLEnum(MeasurementUnit), nullable=True)
|
||||
|
||||
# Ingredient specifications
|
||||
preparation_method = Column(String(255), nullable=True) # "sifted", "room temperature", "chopped"
|
||||
ingredient_notes = Column(Text, nullable=True) # Special instructions for this ingredient
|
||||
is_optional = Column(Boolean, default=False)
|
||||
|
||||
# Recipe organization
|
||||
ingredient_order = Column(Integer, nullable=False, default=1) # Order in recipe
|
||||
ingredient_group = Column(String(100), nullable=True) # "wet ingredients", "dry ingredients", etc.
|
||||
|
||||
# Substitutions
|
||||
substitution_options = Column(JSONB, nullable=True) # Alternative ingredients
|
||||
substitution_ratio = Column(Float, nullable=True) # 1:1, 1:2, etc.
|
||||
|
||||
# Cost tracking
|
||||
unit_cost = Column(Numeric(10, 2), nullable=True)
|
||||
total_cost = Column(Numeric(10, 2), nullable=True)
|
||||
cost_updated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
recipe = relationship("Recipe", back_populates="ingredients")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_recipe_ingredients_recipe', 'recipe_id', 'ingredient_order'),
|
||||
Index('idx_recipe_ingredients_ingredient', 'ingredient_id'),
|
||||
Index('idx_recipe_ingredients_tenant', 'tenant_id', 'recipe_id'),
|
||||
Index('idx_recipe_ingredients_group', 'recipe_id', 'ingredient_group', 'ingredient_order'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'recipe_id': str(self.recipe_id),
|
||||
'ingredient_id': str(self.ingredient_id),
|
||||
'quantity': self.quantity,
|
||||
'unit': self.unit.value if self.unit else None,
|
||||
'quantity_in_base_unit': self.quantity_in_base_unit,
|
||||
'alternative_quantity': self.alternative_quantity,
|
||||
'alternative_unit': self.alternative_unit.value if self.alternative_unit else None,
|
||||
'preparation_method': self.preparation_method,
|
||||
'ingredient_notes': self.ingredient_notes,
|
||||
'is_optional': self.is_optional,
|
||||
'ingredient_order': self.ingredient_order,
|
||||
'ingredient_group': self.ingredient_group,
|
||||
'substitution_options': self.substitution_options,
|
||||
'substitution_ratio': self.substitution_ratio,
|
||||
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
|
||||
'total_cost': float(self.total_cost) if self.total_cost else None,
|
||||
'cost_updated_at': self.cost_updated_at.isoformat() if self.cost_updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ProductionBatch(Base):
|
||||
"""Track production batches and inventory consumption"""
|
||||
__tablename__ = "production_batches"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
recipe_id = Column(UUID(as_uuid=True), ForeignKey('recipes.id'), nullable=False, index=True)
|
||||
|
||||
# Batch identification
|
||||
batch_number = Column(String(100), nullable=False, index=True)
|
||||
production_date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
planned_start_time = Column(DateTime(timezone=True), nullable=True)
|
||||
actual_start_time = Column(DateTime(timezone=True), nullable=True)
|
||||
planned_end_time = Column(DateTime(timezone=True), nullable=True)
|
||||
actual_end_time = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Production planning
|
||||
planned_quantity = Column(Float, nullable=False)
|
||||
actual_quantity = Column(Float, nullable=True)
|
||||
yield_percentage = Column(Float, nullable=True) # actual/planned * 100
|
||||
batch_size_multiplier = Column(Float, nullable=False, default=1.0)
|
||||
|
||||
# Production details
|
||||
status = Column(SQLEnum(ProductionStatus), nullable=False, default=ProductionStatus.PLANNED, index=True)
|
||||
priority = Column(SQLEnum(ProductionPriority), nullable=False, default=ProductionPriority.NORMAL)
|
||||
assigned_staff = Column(JSONB, nullable=True) # List of staff assigned to this batch
|
||||
production_notes = Column(Text, nullable=True)
|
||||
|
||||
# Quality metrics
|
||||
quality_score = Column(Float, nullable=True) # 1-10 scale
|
||||
quality_notes = Column(Text, nullable=True)
|
||||
defect_rate = Column(Float, nullable=True) # Percentage of defective products
|
||||
rework_required = Column(Boolean, default=False)
|
||||
|
||||
# Cost tracking
|
||||
planned_material_cost = Column(Numeric(10, 2), nullable=True)
|
||||
actual_material_cost = Column(Numeric(10, 2), nullable=True)
|
||||
labor_cost = Column(Numeric(10, 2), nullable=True)
|
||||
overhead_cost = Column(Numeric(10, 2), nullable=True)
|
||||
total_production_cost = Column(Numeric(10, 2), nullable=True)
|
||||
cost_per_unit = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Environmental conditions
|
||||
production_temperature = Column(Float, nullable=True)
|
||||
production_humidity = Column(Float, nullable=True)
|
||||
oven_temperature = Column(Float, nullable=True)
|
||||
baking_time_minutes = Column(Integer, nullable=True)
|
||||
|
||||
# Waste and efficiency
|
||||
waste_quantity = Column(Float, nullable=False, default=0.0)
|
||||
waste_reason = Column(String(255), nullable=True)
|
||||
efficiency_percentage = Column(Float, nullable=True) # Based on time vs planned
|
||||
|
||||
# Sales integration
|
||||
customer_order_reference = Column(String(100), nullable=True) # If made to order
|
||||
pre_order_quantity = Column(Float, nullable=True) # Pre-sold quantity
|
||||
shelf_quantity = Column(Float, nullable=True) # For shelf/display
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
completed_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
recipe = relationship("Recipe", back_populates="production_batches")
|
||||
ingredient_consumptions = relationship("ProductionIngredientConsumption", back_populates="production_batch", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_production_batches_tenant_date', 'tenant_id', 'production_date'),
|
||||
Index('idx_production_batches_recipe', 'recipe_id', 'production_date'),
|
||||
Index('idx_production_batches_status', 'tenant_id', 'status', 'production_date'),
|
||||
Index('idx_production_batches_batch_number', 'tenant_id', 'batch_number'),
|
||||
Index('idx_production_batches_priority', 'tenant_id', 'priority', 'planned_start_time'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'recipe_id': str(self.recipe_id),
|
||||
'batch_number': self.batch_number,
|
||||
'production_date': self.production_date.isoformat() if self.production_date else None,
|
||||
'planned_start_time': self.planned_start_time.isoformat() if self.planned_start_time else None,
|
||||
'actual_start_time': self.actual_start_time.isoformat() if self.actual_start_time else None,
|
||||
'planned_end_time': self.planned_end_time.isoformat() if self.planned_end_time else None,
|
||||
'actual_end_time': self.actual_end_time.isoformat() if self.actual_end_time else None,
|
||||
'planned_quantity': self.planned_quantity,
|
||||
'actual_quantity': self.actual_quantity,
|
||||
'yield_percentage': self.yield_percentage,
|
||||
'batch_size_multiplier': self.batch_size_multiplier,
|
||||
'status': self.status.value if self.status else None,
|
||||
'priority': self.priority.value if self.priority else None,
|
||||
'assigned_staff': self.assigned_staff,
|
||||
'production_notes': self.production_notes,
|
||||
'quality_score': self.quality_score,
|
||||
'quality_notes': self.quality_notes,
|
||||
'defect_rate': self.defect_rate,
|
||||
'rework_required': self.rework_required,
|
||||
'planned_material_cost': float(self.planned_material_cost) if self.planned_material_cost else None,
|
||||
'actual_material_cost': float(self.actual_material_cost) if self.actual_material_cost else None,
|
||||
'labor_cost': float(self.labor_cost) if self.labor_cost else None,
|
||||
'overhead_cost': float(self.overhead_cost) if self.overhead_cost else None,
|
||||
'total_production_cost': float(self.total_production_cost) if self.total_production_cost else None,
|
||||
'cost_per_unit': float(self.cost_per_unit) if self.cost_per_unit else None,
|
||||
'production_temperature': self.production_temperature,
|
||||
'production_humidity': self.production_humidity,
|
||||
'oven_temperature': self.oven_temperature,
|
||||
'baking_time_minutes': self.baking_time_minutes,
|
||||
'waste_quantity': self.waste_quantity,
|
||||
'waste_reason': self.waste_reason,
|
||||
'efficiency_percentage': self.efficiency_percentage,
|
||||
'customer_order_reference': self.customer_order_reference,
|
||||
'pre_order_quantity': self.pre_order_quantity,
|
||||
'shelf_quantity': self.shelf_quantity,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'created_by': str(self.created_by) if self.created_by else None,
|
||||
'completed_by': str(self.completed_by) if self.completed_by else None,
|
||||
}
|
||||
|
||||
|
||||
class ProductionIngredientConsumption(Base):
|
||||
"""Track actual ingredient consumption during production"""
|
||||
__tablename__ = "production_ingredient_consumption"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
production_batch_id = Column(UUID(as_uuid=True), ForeignKey('production_batches.id'), nullable=False, index=True)
|
||||
recipe_ingredient_id = Column(UUID(as_uuid=True), ForeignKey('recipe_ingredients.id'), nullable=False, index=True)
|
||||
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Links to inventory ingredients
|
||||
stock_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Specific stock batch used
|
||||
|
||||
# Consumption details
|
||||
planned_quantity = Column(Float, nullable=False)
|
||||
actual_quantity = Column(Float, nullable=False)
|
||||
unit = Column(SQLEnum(MeasurementUnit), nullable=False)
|
||||
variance_quantity = Column(Float, nullable=True) # actual - planned
|
||||
variance_percentage = Column(Float, nullable=True) # (actual - planned) / planned * 100
|
||||
|
||||
# Cost tracking
|
||||
unit_cost = Column(Numeric(10, 2), nullable=True)
|
||||
total_cost = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Consumption details
|
||||
consumption_time = Column(DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
consumption_notes = Column(Text, nullable=True)
|
||||
staff_member = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Quality and condition
|
||||
ingredient_condition = Column(String(50), nullable=True) # fresh, near_expiry, etc.
|
||||
quality_impact = Column(String(255), nullable=True) # Impact on final product quality
|
||||
substitution_used = Column(Boolean, default=False)
|
||||
substitution_details = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
production_batch = relationship("ProductionBatch", back_populates="ingredient_consumptions")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_consumption_batch', 'production_batch_id'),
|
||||
Index('idx_consumption_ingredient', 'ingredient_id', 'consumption_time'),
|
||||
Index('idx_consumption_tenant', 'tenant_id', 'consumption_time'),
|
||||
Index('idx_consumption_recipe_ingredient', 'recipe_ingredient_id'),
|
||||
Index('idx_consumption_stock', 'stock_id'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'production_batch_id': str(self.production_batch_id),
|
||||
'recipe_ingredient_id': str(self.recipe_ingredient_id),
|
||||
'ingredient_id': str(self.ingredient_id),
|
||||
'stock_id': str(self.stock_id) if self.stock_id else None,
|
||||
'planned_quantity': self.planned_quantity,
|
||||
'actual_quantity': self.actual_quantity,
|
||||
'unit': self.unit.value if self.unit else None,
|
||||
'variance_quantity': self.variance_quantity,
|
||||
'variance_percentage': self.variance_percentage,
|
||||
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
|
||||
'total_cost': float(self.total_cost) if self.total_cost else None,
|
||||
'consumption_time': self.consumption_time.isoformat() if self.consumption_time else None,
|
||||
'consumption_notes': self.consumption_notes,
|
||||
'staff_member': str(self.staff_member) if self.staff_member else None,
|
||||
'ingredient_condition': self.ingredient_condition,
|
||||
'quality_impact': self.quality_impact,
|
||||
'substitution_used': self.substitution_used,
|
||||
'substitution_details': self.substitution_details,
|
||||
}
|
||||
|
||||
|
||||
class ProductionSchedule(Base):
|
||||
"""Production planning and scheduling"""
|
||||
__tablename__ = "production_schedules"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Schedule details
|
||||
schedule_date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
schedule_name = Column(String(255), nullable=True)
|
||||
|
||||
# Production planning
|
||||
total_planned_batches = Column(Integer, nullable=False, default=0)
|
||||
total_planned_items = Column(Float, nullable=False, default=0.0)
|
||||
estimated_production_hours = Column(Float, nullable=True)
|
||||
estimated_material_cost = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Schedule status
|
||||
is_published = Column(Boolean, default=False)
|
||||
is_completed = Column(Boolean, default=False)
|
||||
completion_percentage = Column(Float, nullable=True)
|
||||
|
||||
# Planning constraints
|
||||
available_staff_hours = Column(Float, nullable=True)
|
||||
oven_capacity_hours = Column(Float, nullable=True)
|
||||
production_capacity_limit = Column(Float, nullable=True)
|
||||
|
||||
# Notes and instructions
|
||||
schedule_notes = Column(Text, nullable=True)
|
||||
preparation_instructions = Column(Text, nullable=True)
|
||||
special_requirements = Column(JSONB, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
published_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
published_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_production_schedules_tenant_date', 'tenant_id', 'schedule_date'),
|
||||
Index('idx_production_schedules_published', 'tenant_id', 'is_published', 'schedule_date'),
|
||||
Index('idx_production_schedules_completed', 'tenant_id', 'is_completed', 'schedule_date'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'schedule_date': self.schedule_date.isoformat() if self.schedule_date else None,
|
||||
'schedule_name': self.schedule_name,
|
||||
'total_planned_batches': self.total_planned_batches,
|
||||
'total_planned_items': self.total_planned_items,
|
||||
'estimated_production_hours': self.estimated_production_hours,
|
||||
'estimated_material_cost': float(self.estimated_material_cost) if self.estimated_material_cost else None,
|
||||
'is_published': self.is_published,
|
||||
'is_completed': self.is_completed,
|
||||
'completion_percentage': self.completion_percentage,
|
||||
'available_staff_hours': self.available_staff_hours,
|
||||
'oven_capacity_hours': self.oven_capacity_hours,
|
||||
'production_capacity_limit': self.production_capacity_limit,
|
||||
'schedule_notes': self.schedule_notes,
|
||||
'preparation_instructions': self.preparation_instructions,
|
||||
'special_requirements': self.special_requirements,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'created_by': str(self.created_by) if self.created_by else None,
|
||||
'published_by': str(self.published_by) if self.published_by else None,
|
||||
'published_at': self.published_at.isoformat() if self.published_at else None,
|
||||
}
|
||||
11
services/recipes/app/repositories/__init__.py
Normal file
11
services/recipes/app/repositories/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# services/recipes/app/repositories/__init__.py
|
||||
|
||||
from .base import BaseRepository
|
||||
from .recipe_repository import RecipeRepository
|
||||
from .production_repository import ProductionRepository
|
||||
|
||||
__all__ = [
|
||||
"BaseRepository",
|
||||
"RecipeRepository",
|
||||
"ProductionRepository"
|
||||
]
|
||||
96
services/recipes/app/repositories/base.py
Normal file
96
services/recipes/app/repositories/base.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# services/recipes/app/repositories/base.py
|
||||
"""
|
||||
Base repository class for common database operations
|
||||
"""
|
||||
|
||||
from typing import TypeVar, Generic, List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, asc
|
||||
from uuid import UUID
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Base repository with common CRUD operations"""
|
||||
|
||||
def __init__(self, model: type, db: Session):
|
||||
self.model = model
|
||||
self.db = db
|
||||
|
||||
def create(self, obj_data: Dict[str, Any]) -> T:
|
||||
"""Create a new record"""
|
||||
db_obj = self.model(**obj_data)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def get_by_id(self, record_id: UUID) -> Optional[T]:
|
||||
"""Get record by ID"""
|
||||
return self.db.query(self.model).filter(self.model.id == record_id).first()
|
||||
|
||||
def get_by_tenant_id(self, tenant_id: UUID, limit: int = 100, offset: int = 0) -> List[T]:
|
||||
"""Get records by tenant ID with pagination"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(self.model.tenant_id == tenant_id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update(self, record_id: UUID, update_data: Dict[str, Any]) -> Optional[T]:
|
||||
"""Update record by ID"""
|
||||
db_obj = self.get_by_id(record_id)
|
||||
if db_obj:
|
||||
for key, value in update_data.items():
|
||||
if hasattr(db_obj, key):
|
||||
setattr(db_obj, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def delete(self, record_id: UUID) -> bool:
|
||||
"""Delete record by ID"""
|
||||
db_obj = self.get_by_id(record_id)
|
||||
if db_obj:
|
||||
self.db.delete(db_obj)
|
||||
self.db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def count_by_tenant(self, tenant_id: UUID) -> int:
|
||||
"""Count records by tenant"""
|
||||
return self.db.query(self.model).filter(self.model.tenant_id == tenant_id).count()
|
||||
|
||||
def list_with_filters(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[T]:
|
||||
"""List records with filtering and sorting"""
|
||||
query = self.db.query(self.model).filter(self.model.tenant_id == tenant_id)
|
||||
|
||||
# Apply filters
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
if hasattr(self.model, key) and value is not None:
|
||||
query = query.filter(getattr(self.model, key) == value)
|
||||
|
||||
# Apply sorting
|
||||
if hasattr(self.model, sort_by):
|
||||
if sort_order.lower() == "desc":
|
||||
query = query.order_by(desc(getattr(self.model, sort_by)))
|
||||
else:
|
||||
query = query.order_by(asc(getattr(self.model, sort_by)))
|
||||
|
||||
return query.limit(limit).offset(offset).all()
|
||||
|
||||
def exists(self, record_id: UUID) -> bool:
|
||||
"""Check if record exists"""
|
||||
return self.db.query(self.model).filter(self.model.id == record_id).first() is not None
|
||||
382
services/recipes/app/repositories/production_repository.py
Normal file
382
services/recipes/app/repositories/production_repository.py
Normal file
@@ -0,0 +1,382 @@
|
||||
# services/recipes/app/repositories/production_repository.py
|
||||
"""
|
||||
Repository for production-related database operations
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, func, desc, asc
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
from .base import BaseRepository
|
||||
from ..models.recipes import (
|
||||
ProductionBatch,
|
||||
ProductionIngredientConsumption,
|
||||
ProductionSchedule,
|
||||
ProductionStatus,
|
||||
ProductionPriority
|
||||
)
|
||||
|
||||
|
||||
class ProductionRepository(BaseRepository[ProductionBatch]):
|
||||
"""Repository for production batch operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(ProductionBatch, db)
|
||||
|
||||
def get_by_id_with_consumptions(self, batch_id: UUID) -> Optional[ProductionBatch]:
|
||||
"""Get production batch with ingredient consumptions loaded"""
|
||||
return (
|
||||
self.db.query(ProductionBatch)
|
||||
.options(joinedload(ProductionBatch.ingredient_consumptions))
|
||||
.filter(ProductionBatch.id == batch_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_batches_by_date_range(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
status: Optional[ProductionStatus] = None
|
||||
) -> List[ProductionBatch]:
|
||||
"""Get production batches within date range"""
|
||||
query = (
|
||||
self.db.query(ProductionBatch)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.production_date >= start_date,
|
||||
ProductionBatch.production_date <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(ProductionBatch.status == status)
|
||||
|
||||
return query.order_by(ProductionBatch.production_date, ProductionBatch.planned_start_time).all()
|
||||
|
||||
def get_active_batches(self, tenant_id: UUID) -> List[ProductionBatch]:
|
||||
"""Get all active production batches"""
|
||||
return (
|
||||
self.db.query(ProductionBatch)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.status.in_([
|
||||
ProductionStatus.PLANNED,
|
||||
ProductionStatus.IN_PROGRESS
|
||||
])
|
||||
)
|
||||
)
|
||||
.order_by(ProductionBatch.planned_start_time)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_batches_by_recipe(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
limit: int = 50
|
||||
) -> List[ProductionBatch]:
|
||||
"""Get recent production batches for a specific recipe"""
|
||||
return (
|
||||
self.db.query(ProductionBatch)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.recipe_id == recipe_id
|
||||
)
|
||||
)
|
||||
.order_by(desc(ProductionBatch.production_date))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def search_batches(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[ProductionStatus] = None,
|
||||
priority: Optional[ProductionPriority] = None,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
recipe_id: Optional[UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ProductionBatch]:
|
||||
"""Search production batches with filters"""
|
||||
query = self.db.query(ProductionBatch).filter(ProductionBatch.tenant_id == tenant_id)
|
||||
|
||||
# Text search
|
||||
if search_term:
|
||||
query = query.filter(ProductionBatch.batch_number.ilike(f"%{search_term}%"))
|
||||
|
||||
# Status filter
|
||||
if status:
|
||||
query = query.filter(ProductionBatch.status == status)
|
||||
|
||||
# Priority filter
|
||||
if priority:
|
||||
query = query.filter(ProductionBatch.priority == priority)
|
||||
|
||||
# Date range filter
|
||||
if start_date:
|
||||
query = query.filter(ProductionBatch.production_date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ProductionBatch.production_date <= end_date)
|
||||
|
||||
# Recipe filter
|
||||
if recipe_id:
|
||||
query = query.filter(ProductionBatch.recipe_id == recipe_id)
|
||||
|
||||
return (
|
||||
query.order_by(desc(ProductionBatch.production_date))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_production_statistics(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get production statistics for dashboard"""
|
||||
query = self.db.query(ProductionBatch).filter(ProductionBatch.tenant_id == tenant_id)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ProductionBatch.production_date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ProductionBatch.production_date <= end_date)
|
||||
|
||||
# Total batches
|
||||
total_batches = query.count()
|
||||
|
||||
# Completed batches
|
||||
completed_batches = query.filter(ProductionBatch.status == ProductionStatus.COMPLETED).count()
|
||||
|
||||
# Failed batches
|
||||
failed_batches = query.filter(ProductionBatch.status == ProductionStatus.FAILED).count()
|
||||
|
||||
# Average yield
|
||||
avg_yield = (
|
||||
self.db.query(func.avg(ProductionBatch.yield_percentage))
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.status == ProductionStatus.COMPLETED,
|
||||
ProductionBatch.yield_percentage.isnot(None)
|
||||
)
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Average quality score
|
||||
avg_quality = (
|
||||
self.db.query(func.avg(ProductionBatch.quality_score))
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.status == ProductionStatus.COMPLETED,
|
||||
ProductionBatch.quality_score.isnot(None)
|
||||
)
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Total production cost
|
||||
total_cost = (
|
||||
self.db.query(func.sum(ProductionBatch.total_production_cost))
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.status == ProductionStatus.COMPLETED,
|
||||
ProductionBatch.total_production_cost.isnot(None)
|
||||
)
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Status breakdown
|
||||
status_stats = (
|
||||
self.db.query(ProductionBatch.status, func.count(ProductionBatch.id))
|
||||
.filter(ProductionBatch.tenant_id == tenant_id)
|
||||
.group_by(ProductionBatch.status)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_batches": total_batches,
|
||||
"completed_batches": completed_batches,
|
||||
"failed_batches": failed_batches,
|
||||
"success_rate": (completed_batches / total_batches * 100) if total_batches > 0 else 0,
|
||||
"average_yield_percentage": float(avg_yield) if avg_yield else 0,
|
||||
"average_quality_score": float(avg_quality) if avg_quality else 0,
|
||||
"total_production_cost": float(total_cost) if total_cost else 0,
|
||||
"status_breakdown": [
|
||||
{"status": status.value, "count": count}
|
||||
for status, count in status_stats
|
||||
]
|
||||
}
|
||||
|
||||
def update_batch_status(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
status: ProductionStatus,
|
||||
completed_by: Optional[UUID] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[ProductionBatch]:
|
||||
"""Update production batch status"""
|
||||
batch = self.get_by_id(batch_id)
|
||||
if batch:
|
||||
batch.status = status
|
||||
if status == ProductionStatus.COMPLETED and completed_by:
|
||||
batch.completed_by = completed_by
|
||||
batch.actual_end_time = datetime.utcnow()
|
||||
if status == ProductionStatus.IN_PROGRESS and not batch.actual_start_time:
|
||||
batch.actual_start_time = datetime.utcnow()
|
||||
if notes:
|
||||
batch.production_notes = notes
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(batch)
|
||||
return batch
|
||||
|
||||
|
||||
class ProductionIngredientConsumptionRepository(BaseRepository[ProductionIngredientConsumption]):
|
||||
"""Repository for production ingredient consumption operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(ProductionIngredientConsumption, db)
|
||||
|
||||
def get_by_batch_id(self, batch_id: UUID) -> List[ProductionIngredientConsumption]:
|
||||
"""Get all ingredient consumptions for a production batch"""
|
||||
return (
|
||||
self.db.query(ProductionIngredientConsumption)
|
||||
.filter(ProductionIngredientConsumption.production_batch_id == batch_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_by_ingredient_id(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> List[ProductionIngredientConsumption]:
|
||||
"""Get ingredient consumptions by ingredient ID"""
|
||||
query = (
|
||||
self.db.query(ProductionIngredientConsumption)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionIngredientConsumption.tenant_id == tenant_id,
|
||||
ProductionIngredientConsumption.ingredient_id == ingredient_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ProductionIngredientConsumption.consumption_time >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ProductionIngredientConsumption.consumption_time <= end_date)
|
||||
|
||||
return query.order_by(desc(ProductionIngredientConsumption.consumption_time)).all()
|
||||
|
||||
def calculate_ingredient_usage(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> Dict[str, Any]:
|
||||
"""Calculate ingredient usage statistics"""
|
||||
consumptions = self.get_by_ingredient_id(tenant_id, ingredient_id, start_date, end_date)
|
||||
|
||||
if not consumptions:
|
||||
return {
|
||||
"total_consumed": 0,
|
||||
"average_per_batch": 0,
|
||||
"total_cost": 0,
|
||||
"variance_percentage": 0,
|
||||
"consumption_count": 0
|
||||
}
|
||||
|
||||
total_consumed = sum(c.actual_quantity for c in consumptions)
|
||||
total_planned = sum(c.planned_quantity for c in consumptions)
|
||||
total_cost = sum((c.total_cost or 0) for c in consumptions)
|
||||
|
||||
variance_percentage = 0
|
||||
if total_planned > 0:
|
||||
variance_percentage = ((total_consumed - total_planned) / total_planned) * 100
|
||||
|
||||
return {
|
||||
"total_consumed": total_consumed,
|
||||
"total_planned": total_planned,
|
||||
"average_per_batch": total_consumed / len(consumptions),
|
||||
"total_cost": float(total_cost),
|
||||
"variance_percentage": variance_percentage,
|
||||
"consumption_count": len(consumptions)
|
||||
}
|
||||
|
||||
|
||||
class ProductionScheduleRepository(BaseRepository[ProductionSchedule]):
|
||||
"""Repository for production schedule operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(ProductionSchedule, db)
|
||||
|
||||
def get_by_date(self, tenant_id: UUID, schedule_date: date) -> Optional[ProductionSchedule]:
|
||||
"""Get production schedule for specific date"""
|
||||
return (
|
||||
self.db.query(ProductionSchedule)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionSchedule.tenant_id == tenant_id,
|
||||
ProductionSchedule.schedule_date == schedule_date
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_published_schedules(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[ProductionSchedule]:
|
||||
"""Get published schedules within date range"""
|
||||
return (
|
||||
self.db.query(ProductionSchedule)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionSchedule.tenant_id == tenant_id,
|
||||
ProductionSchedule.is_published == True,
|
||||
ProductionSchedule.schedule_date >= start_date,
|
||||
ProductionSchedule.schedule_date <= end_date
|
||||
)
|
||||
)
|
||||
.order_by(ProductionSchedule.schedule_date)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_upcoming_schedules(self, tenant_id: UUID, days_ahead: int = 7) -> List[ProductionSchedule]:
|
||||
"""Get upcoming production schedules"""
|
||||
start_date = date.today()
|
||||
end_date = date.today() + timedelta(days=days_ahead)
|
||||
|
||||
return (
|
||||
self.db.query(ProductionSchedule)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionSchedule.tenant_id == tenant_id,
|
||||
ProductionSchedule.schedule_date >= start_date,
|
||||
ProductionSchedule.schedule_date <= end_date
|
||||
)
|
||||
)
|
||||
.order_by(ProductionSchedule.schedule_date)
|
||||
.all()
|
||||
)
|
||||
343
services/recipes/app/repositories/recipe_repository.py
Normal file
343
services/recipes/app/repositories/recipe_repository.py
Normal file
@@ -0,0 +1,343 @@
|
||||
# services/recipes/app/repositories/recipe_repository.py
|
||||
"""
|
||||
Repository for recipe-related database operations
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, or_, func
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
from .base import BaseRepository
|
||||
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
|
||||
|
||||
|
||||
class RecipeRepository(BaseRepository[Recipe]):
|
||||
"""Repository for recipe operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(Recipe, db)
|
||||
|
||||
def get_by_id_with_ingredients(self, recipe_id: UUID) -> Optional[Recipe]:
|
||||
"""Get recipe with ingredients loaded"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.options(joinedload(Recipe.ingredients))
|
||||
.filter(Recipe.id == recipe_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_by_finished_product_id(self, tenant_id: UUID, finished_product_id: UUID) -> Optional[Recipe]:
|
||||
"""Get recipe by finished product ID"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.finished_product_id == finished_product_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def search_recipes(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[RecipeStatus] = None,
|
||||
category: Optional[str] = None,
|
||||
is_seasonal: Optional[bool] = None,
|
||||
is_signature: Optional[bool] = None,
|
||||
difficulty_level: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[Recipe]:
|
||||
"""Search recipes with multiple filters"""
|
||||
query = self.db.query(Recipe).filter(Recipe.tenant_id == tenant_id)
|
||||
|
||||
# Text search
|
||||
if search_term:
|
||||
search_filter = or_(
|
||||
Recipe.name.ilike(f"%{search_term}%"),
|
||||
Recipe.description.ilike(f"%{search_term}%"),
|
||||
Recipe.category.ilike(f"%{search_term}%")
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
|
||||
# Status filter
|
||||
if status:
|
||||
query = query.filter(Recipe.status == status)
|
||||
|
||||
# Category filter
|
||||
if category:
|
||||
query = query.filter(Recipe.category == category)
|
||||
|
||||
# Seasonal filter
|
||||
if is_seasonal is not None:
|
||||
query = query.filter(Recipe.is_seasonal == is_seasonal)
|
||||
|
||||
# Signature item filter
|
||||
if is_signature is not None:
|
||||
query = query.filter(Recipe.is_signature_item == is_signature)
|
||||
|
||||
# Difficulty level filter
|
||||
if difficulty_level is not None:
|
||||
query = query.filter(Recipe.difficulty_level == difficulty_level)
|
||||
|
||||
return query.order_by(Recipe.name).limit(limit).offset(offset).all()
|
||||
|
||||
def get_active_recipes(self, tenant_id: UUID) -> List[Recipe]:
|
||||
"""Get all active recipes for tenant"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.order_by(Recipe.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_seasonal_recipes(self, tenant_id: UUID, current_month: int) -> List[Recipe]:
|
||||
"""Get seasonal recipes for current month"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE,
|
||||
Recipe.is_seasonal == True,
|
||||
or_(
|
||||
and_(
|
||||
Recipe.season_start_month <= current_month,
|
||||
Recipe.season_end_month >= current_month
|
||||
),
|
||||
and_(
|
||||
Recipe.season_start_month > Recipe.season_end_month, # Crosses year boundary
|
||||
or_(
|
||||
Recipe.season_start_month <= current_month,
|
||||
Recipe.season_end_month >= current_month
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(Recipe.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_recipes_by_category(self, tenant_id: UUID, category: str) -> List[Recipe]:
|
||||
"""Get recipes by category"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.category == category,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.order_by(Recipe.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get recipe statistics for dashboard"""
|
||||
total_recipes = self.db.query(Recipe).filter(Recipe.tenant_id == tenant_id).count()
|
||||
|
||||
active_recipes = (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
signature_recipes = (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.is_signature_item == True,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
seasonal_recipes = (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.is_seasonal == True,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Category breakdown
|
||||
category_stats = (
|
||||
self.db.query(Recipe.category, func.count(Recipe.id))
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.group_by(Recipe.category)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_recipes": total_recipes,
|
||||
"active_recipes": active_recipes,
|
||||
"signature_recipes": signature_recipes,
|
||||
"seasonal_recipes": seasonal_recipes,
|
||||
"category_breakdown": [
|
||||
{"category": cat, "count": count}
|
||||
for cat, count in category_stats
|
||||
]
|
||||
}
|
||||
|
||||
def duplicate_recipe(self, recipe_id: UUID, new_name: str, created_by: UUID) -> Optional[Recipe]:
|
||||
"""Create a duplicate of an existing recipe"""
|
||||
original = self.get_by_id_with_ingredients(recipe_id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
# Create new recipe
|
||||
recipe_data = {
|
||||
"tenant_id": original.tenant_id,
|
||||
"name": new_name,
|
||||
"recipe_code": f"{original.recipe_code}_copy" if original.recipe_code else None,
|
||||
"version": "1.0",
|
||||
"finished_product_id": original.finished_product_id,
|
||||
"description": original.description,
|
||||
"category": original.category,
|
||||
"cuisine_type": original.cuisine_type,
|
||||
"difficulty_level": original.difficulty_level,
|
||||
"yield_quantity": original.yield_quantity,
|
||||
"yield_unit": original.yield_unit,
|
||||
"prep_time_minutes": original.prep_time_minutes,
|
||||
"cook_time_minutes": original.cook_time_minutes,
|
||||
"total_time_minutes": original.total_time_minutes,
|
||||
"rest_time_minutes": original.rest_time_minutes,
|
||||
"instructions": original.instructions,
|
||||
"preparation_notes": original.preparation_notes,
|
||||
"storage_instructions": original.storage_instructions,
|
||||
"quality_standards": original.quality_standards,
|
||||
"serves_count": original.serves_count,
|
||||
"nutritional_info": original.nutritional_info,
|
||||
"allergen_info": original.allergen_info,
|
||||
"dietary_tags": original.dietary_tags,
|
||||
"batch_size_multiplier": original.batch_size_multiplier,
|
||||
"minimum_batch_size": original.minimum_batch_size,
|
||||
"maximum_batch_size": original.maximum_batch_size,
|
||||
"optimal_production_temperature": original.optimal_production_temperature,
|
||||
"optimal_humidity": original.optimal_humidity,
|
||||
"quality_check_points": original.quality_check_points,
|
||||
"common_issues": original.common_issues,
|
||||
"status": RecipeStatus.DRAFT,
|
||||
"is_seasonal": original.is_seasonal,
|
||||
"season_start_month": original.season_start_month,
|
||||
"season_end_month": original.season_end_month,
|
||||
"is_signature_item": False,
|
||||
"created_by": created_by
|
||||
}
|
||||
|
||||
new_recipe = self.create(recipe_data)
|
||||
|
||||
# Copy ingredients
|
||||
for ingredient in original.ingredients:
|
||||
ingredient_data = {
|
||||
"tenant_id": original.tenant_id,
|
||||
"recipe_id": new_recipe.id,
|
||||
"ingredient_id": ingredient.ingredient_id,
|
||||
"quantity": ingredient.quantity,
|
||||
"unit": ingredient.unit,
|
||||
"quantity_in_base_unit": ingredient.quantity_in_base_unit,
|
||||
"alternative_quantity": ingredient.alternative_quantity,
|
||||
"alternative_unit": ingredient.alternative_unit,
|
||||
"preparation_method": ingredient.preparation_method,
|
||||
"ingredient_notes": ingredient.ingredient_notes,
|
||||
"is_optional": ingredient.is_optional,
|
||||
"ingredient_order": ingredient.ingredient_order,
|
||||
"ingredient_group": ingredient.ingredient_group,
|
||||
"substitution_options": ingredient.substitution_options,
|
||||
"substitution_ratio": ingredient.substitution_ratio
|
||||
}
|
||||
|
||||
recipe_ingredient = RecipeIngredient(**ingredient_data)
|
||||
self.db.add(recipe_ingredient)
|
||||
|
||||
self.db.commit()
|
||||
return new_recipe
|
||||
|
||||
|
||||
class RecipeIngredientRepository(BaseRepository[RecipeIngredient]):
|
||||
"""Repository for recipe ingredient operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(RecipeIngredient, db)
|
||||
|
||||
def get_by_recipe_id(self, recipe_id: UUID) -> List[RecipeIngredient]:
|
||||
"""Get all ingredients for a recipe"""
|
||||
return (
|
||||
self.db.query(RecipeIngredient)
|
||||
.filter(RecipeIngredient.recipe_id == recipe_id)
|
||||
.order_by(RecipeIngredient.ingredient_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_by_ingredient_group(self, recipe_id: UUID, ingredient_group: str) -> List[RecipeIngredient]:
|
||||
"""Get ingredients by group within a recipe"""
|
||||
return (
|
||||
self.db.query(RecipeIngredient)
|
||||
.filter(
|
||||
and_(
|
||||
RecipeIngredient.recipe_id == recipe_id,
|
||||
RecipeIngredient.ingredient_group == ingredient_group
|
||||
)
|
||||
)
|
||||
.order_by(RecipeIngredient.ingredient_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update_ingredients_for_recipe(
|
||||
self,
|
||||
recipe_id: UUID,
|
||||
ingredients_data: List[Dict[str, Any]]
|
||||
) -> List[RecipeIngredient]:
|
||||
"""Update all ingredients for a recipe"""
|
||||
# Delete existing ingredients
|
||||
self.db.query(RecipeIngredient).filter(
|
||||
RecipeIngredient.recipe_id == recipe_id
|
||||
).delete()
|
||||
|
||||
# Create new ingredients
|
||||
new_ingredients = []
|
||||
for ingredient_data in ingredients_data:
|
||||
ingredient_data["recipe_id"] = recipe_id
|
||||
ingredient = RecipeIngredient(**ingredient_data)
|
||||
self.db.add(ingredient)
|
||||
new_ingredients.append(ingredient)
|
||||
|
||||
self.db.commit()
|
||||
return new_ingredients
|
||||
|
||||
def calculate_recipe_cost(self, recipe_id: UUID) -> float:
|
||||
"""Calculate total cost of recipe based on ingredient costs"""
|
||||
ingredients = self.get_by_recipe_id(recipe_id)
|
||||
total_cost = sum(
|
||||
(ingredient.total_cost or 0) for ingredient in ingredients
|
||||
)
|
||||
return total_cost
|
||||
37
services/recipes/app/schemas/__init__.py
Normal file
37
services/recipes/app/schemas/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# services/recipes/app/schemas/__init__.py
|
||||
|
||||
from .recipes import (
|
||||
RecipeCreate,
|
||||
RecipeUpdate,
|
||||
RecipeResponse,
|
||||
RecipeIngredientCreate,
|
||||
RecipeIngredientResponse,
|
||||
RecipeSearchRequest,
|
||||
RecipeFeasibilityResponse
|
||||
)
|
||||
from .production import (
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
ProductionBatchResponse,
|
||||
ProductionIngredientConsumptionCreate,
|
||||
ProductionIngredientConsumptionResponse,
|
||||
ProductionScheduleCreate,
|
||||
ProductionScheduleResponse
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecipeCreate",
|
||||
"RecipeUpdate",
|
||||
"RecipeResponse",
|
||||
"RecipeIngredientCreate",
|
||||
"RecipeIngredientResponse",
|
||||
"RecipeSearchRequest",
|
||||
"RecipeFeasibilityResponse",
|
||||
"ProductionBatchCreate",
|
||||
"ProductionBatchUpdate",
|
||||
"ProductionBatchResponse",
|
||||
"ProductionIngredientConsumptionCreate",
|
||||
"ProductionIngredientConsumptionResponse",
|
||||
"ProductionScheduleCreate",
|
||||
"ProductionScheduleResponse"
|
||||
]
|
||||
257
services/recipes/app/schemas/production.py
Normal file
257
services/recipes/app/schemas/production.py
Normal file
@@ -0,0 +1,257 @@
|
||||
# services/recipes/app/schemas/production.py
|
||||
"""
|
||||
Pydantic schemas for production-related API requests and responses
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date
|
||||
from enum import Enum
|
||||
|
||||
from ..models.recipes import ProductionStatus, ProductionPriority, MeasurementUnit
|
||||
|
||||
|
||||
class ProductionIngredientConsumptionCreate(BaseModel):
|
||||
"""Schema for creating production ingredient consumption"""
|
||||
recipe_ingredient_id: UUID
|
||||
ingredient_id: UUID
|
||||
stock_id: Optional[UUID] = None
|
||||
planned_quantity: float = Field(..., gt=0)
|
||||
actual_quantity: float = Field(..., gt=0)
|
||||
unit: MeasurementUnit
|
||||
consumption_notes: Optional[str] = None
|
||||
staff_member: Optional[UUID] = None
|
||||
ingredient_condition: Optional[str] = None
|
||||
quality_impact: Optional[str] = None
|
||||
substitution_used: bool = False
|
||||
substitution_details: Optional[str] = None
|
||||
|
||||
|
||||
class ProductionIngredientConsumptionResponse(BaseModel):
|
||||
"""Schema for production ingredient consumption responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
production_batch_id: UUID
|
||||
recipe_ingredient_id: UUID
|
||||
ingredient_id: UUID
|
||||
stock_id: Optional[UUID] = None
|
||||
planned_quantity: float
|
||||
actual_quantity: float
|
||||
unit: str
|
||||
variance_quantity: Optional[float] = None
|
||||
variance_percentage: Optional[float] = None
|
||||
unit_cost: Optional[float] = None
|
||||
total_cost: Optional[float] = None
|
||||
consumption_time: datetime
|
||||
consumption_notes: Optional[str] = None
|
||||
staff_member: Optional[UUID] = None
|
||||
ingredient_condition: Optional[str] = None
|
||||
quality_impact: Optional[str] = None
|
||||
substitution_used: bool
|
||||
substitution_details: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductionBatchCreate(BaseModel):
|
||||
"""Schema for creating production batches"""
|
||||
recipe_id: UUID
|
||||
batch_number: str = Field(..., min_length=1, max_length=100)
|
||||
production_date: date
|
||||
planned_start_time: Optional[datetime] = None
|
||||
planned_end_time: Optional[datetime] = None
|
||||
planned_quantity: float = Field(..., gt=0)
|
||||
batch_size_multiplier: float = Field(default=1.0, gt=0)
|
||||
priority: ProductionPriority = ProductionPriority.NORMAL
|
||||
assigned_staff: Optional[List[UUID]] = None
|
||||
production_notes: Optional[str] = None
|
||||
customer_order_reference: Optional[str] = None
|
||||
pre_order_quantity: Optional[float] = Field(None, ge=0)
|
||||
shelf_quantity: Optional[float] = Field(None, ge=0)
|
||||
|
||||
|
||||
class ProductionBatchUpdate(BaseModel):
|
||||
"""Schema for updating production batches"""
|
||||
batch_number: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
production_date: Optional[date] = None
|
||||
planned_start_time: Optional[datetime] = None
|
||||
actual_start_time: Optional[datetime] = None
|
||||
planned_end_time: Optional[datetime] = None
|
||||
actual_end_time: Optional[datetime] = None
|
||||
planned_quantity: Optional[float] = Field(None, gt=0)
|
||||
actual_quantity: Optional[float] = Field(None, ge=0)
|
||||
batch_size_multiplier: Optional[float] = Field(None, gt=0)
|
||||
status: Optional[ProductionStatus] = None
|
||||
priority: Optional[ProductionPriority] = None
|
||||
assigned_staff: Optional[List[UUID]] = None
|
||||
production_notes: Optional[str] = None
|
||||
quality_score: Optional[float] = Field(None, ge=1, le=10)
|
||||
quality_notes: Optional[str] = None
|
||||
defect_rate: Optional[float] = Field(None, ge=0, le=100)
|
||||
rework_required: Optional[bool] = None
|
||||
labor_cost: Optional[float] = Field(None, ge=0)
|
||||
overhead_cost: Optional[float] = Field(None, ge=0)
|
||||
production_temperature: Optional[float] = None
|
||||
production_humidity: Optional[float] = Field(None, ge=0, le=100)
|
||||
oven_temperature: Optional[float] = None
|
||||
baking_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
waste_quantity: Optional[float] = Field(None, ge=0)
|
||||
waste_reason: Optional[str] = None
|
||||
customer_order_reference: Optional[str] = None
|
||||
pre_order_quantity: Optional[float] = Field(None, ge=0)
|
||||
shelf_quantity: Optional[float] = Field(None, ge=0)
|
||||
|
||||
|
||||
class ProductionBatchResponse(BaseModel):
|
||||
"""Schema for production batch responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
recipe_id: UUID
|
||||
batch_number: str
|
||||
production_date: date
|
||||
planned_start_time: Optional[datetime] = None
|
||||
actual_start_time: Optional[datetime] = None
|
||||
planned_end_time: Optional[datetime] = None
|
||||
actual_end_time: Optional[datetime] = None
|
||||
planned_quantity: float
|
||||
actual_quantity: Optional[float] = None
|
||||
yield_percentage: Optional[float] = None
|
||||
batch_size_multiplier: float
|
||||
status: str
|
||||
priority: str
|
||||
assigned_staff: Optional[List[UUID]] = None
|
||||
production_notes: Optional[str] = None
|
||||
quality_score: Optional[float] = None
|
||||
quality_notes: Optional[str] = None
|
||||
defect_rate: Optional[float] = None
|
||||
rework_required: bool
|
||||
planned_material_cost: Optional[float] = None
|
||||
actual_material_cost: Optional[float] = None
|
||||
labor_cost: Optional[float] = None
|
||||
overhead_cost: Optional[float] = None
|
||||
total_production_cost: Optional[float] = None
|
||||
cost_per_unit: Optional[float] = None
|
||||
production_temperature: Optional[float] = None
|
||||
production_humidity: Optional[float] = None
|
||||
oven_temperature: Optional[float] = None
|
||||
baking_time_minutes: Optional[int] = None
|
||||
waste_quantity: float
|
||||
waste_reason: Optional[str] = None
|
||||
efficiency_percentage: Optional[float] = None
|
||||
customer_order_reference: Optional[str] = None
|
||||
pre_order_quantity: Optional[float] = None
|
||||
shelf_quantity: Optional[float] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[UUID] = None
|
||||
completed_by: Optional[UUID] = None
|
||||
ingredient_consumptions: Optional[List[ProductionIngredientConsumptionResponse]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductionBatchSearchRequest(BaseModel):
|
||||
"""Schema for production batch search requests"""
|
||||
search_term: Optional[str] = None
|
||||
status: Optional[ProductionStatus] = None
|
||||
priority: Optional[ProductionPriority] = None
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
recipe_id: Optional[UUID] = None
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class ProductionScheduleCreate(BaseModel):
|
||||
"""Schema for creating production schedules"""
|
||||
schedule_date: date
|
||||
schedule_name: Optional[str] = Field(None, max_length=255)
|
||||
estimated_production_hours: Optional[float] = Field(None, gt=0)
|
||||
estimated_material_cost: Optional[float] = Field(None, ge=0)
|
||||
available_staff_hours: Optional[float] = Field(None, gt=0)
|
||||
oven_capacity_hours: Optional[float] = Field(None, gt=0)
|
||||
production_capacity_limit: Optional[float] = Field(None, gt=0)
|
||||
schedule_notes: Optional[str] = None
|
||||
preparation_instructions: Optional[str] = None
|
||||
special_requirements: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ProductionScheduleUpdate(BaseModel):
|
||||
"""Schema for updating production schedules"""
|
||||
schedule_name: Optional[str] = Field(None, max_length=255)
|
||||
total_planned_batches: Optional[int] = Field(None, ge=0)
|
||||
total_planned_items: Optional[float] = Field(None, ge=0)
|
||||
estimated_production_hours: Optional[float] = Field(None, gt=0)
|
||||
estimated_material_cost: Optional[float] = Field(None, ge=0)
|
||||
is_published: Optional[bool] = None
|
||||
is_completed: Optional[bool] = None
|
||||
completion_percentage: Optional[float] = Field(None, ge=0, le=100)
|
||||
available_staff_hours: Optional[float] = Field(None, gt=0)
|
||||
oven_capacity_hours: Optional[float] = Field(None, gt=0)
|
||||
production_capacity_limit: Optional[float] = Field(None, gt=0)
|
||||
schedule_notes: Optional[str] = None
|
||||
preparation_instructions: Optional[str] = None
|
||||
special_requirements: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ProductionScheduleResponse(BaseModel):
|
||||
"""Schema for production schedule responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
schedule_date: date
|
||||
schedule_name: Optional[str] = None
|
||||
total_planned_batches: int
|
||||
total_planned_items: float
|
||||
estimated_production_hours: Optional[float] = None
|
||||
estimated_material_cost: Optional[float] = None
|
||||
is_published: bool
|
||||
is_completed: bool
|
||||
completion_percentage: Optional[float] = None
|
||||
available_staff_hours: Optional[float] = None
|
||||
oven_capacity_hours: Optional[float] = None
|
||||
production_capacity_limit: Optional[float] = None
|
||||
schedule_notes: Optional[str] = None
|
||||
preparation_instructions: Optional[str] = None
|
||||
special_requirements: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[UUID] = None
|
||||
published_by: Optional[UUID] = None
|
||||
published_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductionStatisticsResponse(BaseModel):
|
||||
"""Schema for production statistics responses"""
|
||||
total_batches: int
|
||||
completed_batches: int
|
||||
failed_batches: int
|
||||
success_rate: float
|
||||
average_yield_percentage: float
|
||||
average_quality_score: float
|
||||
total_production_cost: float
|
||||
status_breakdown: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class StartProductionRequest(BaseModel):
|
||||
"""Schema for starting production batch"""
|
||||
staff_member: Optional[UUID] = None
|
||||
production_notes: Optional[str] = None
|
||||
ingredient_consumptions: List[ProductionIngredientConsumptionCreate]
|
||||
|
||||
|
||||
class CompleteProductionRequest(BaseModel):
|
||||
"""Schema for completing production batch"""
|
||||
actual_quantity: float = Field(..., gt=0)
|
||||
quality_score: Optional[float] = Field(None, ge=1, le=10)
|
||||
quality_notes: Optional[str] = None
|
||||
defect_rate: Optional[float] = Field(None, ge=0, le=100)
|
||||
waste_quantity: Optional[float] = Field(None, ge=0)
|
||||
waste_reason: Optional[str] = None
|
||||
production_notes: Optional[str] = None
|
||||
staff_member: Optional[UUID] = None
|
||||
237
services/recipes/app/schemas/recipes.py
Normal file
237
services/recipes/app/schemas/recipes.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# services/recipes/app/schemas/recipes.py
|
||||
"""
|
||||
Pydantic schemas for recipe-related API requests and responses
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from ..models.recipes import RecipeStatus, MeasurementUnit
|
||||
|
||||
|
||||
class RecipeIngredientCreate(BaseModel):
|
||||
"""Schema for creating recipe ingredients"""
|
||||
ingredient_id: UUID
|
||||
quantity: float = Field(..., gt=0)
|
||||
unit: MeasurementUnit
|
||||
alternative_quantity: Optional[float] = None
|
||||
alternative_unit: Optional[MeasurementUnit] = None
|
||||
preparation_method: Optional[str] = None
|
||||
ingredient_notes: Optional[str] = None
|
||||
is_optional: bool = False
|
||||
ingredient_order: int = Field(..., ge=1)
|
||||
ingredient_group: Optional[str] = None
|
||||
substitution_options: Optional[Dict[str, Any]] = None
|
||||
substitution_ratio: Optional[float] = None
|
||||
|
||||
|
||||
class RecipeIngredientUpdate(BaseModel):
|
||||
"""Schema for updating recipe ingredients"""
|
||||
ingredient_id: Optional[UUID] = None
|
||||
quantity: Optional[float] = Field(None, gt=0)
|
||||
unit: Optional[MeasurementUnit] = None
|
||||
alternative_quantity: Optional[float] = None
|
||||
alternative_unit: Optional[MeasurementUnit] = None
|
||||
preparation_method: Optional[str] = None
|
||||
ingredient_notes: Optional[str] = None
|
||||
is_optional: Optional[bool] = None
|
||||
ingredient_order: Optional[int] = Field(None, ge=1)
|
||||
ingredient_group: Optional[str] = None
|
||||
substitution_options: Optional[Dict[str, Any]] = None
|
||||
substitution_ratio: Optional[float] = None
|
||||
|
||||
|
||||
class RecipeIngredientResponse(BaseModel):
|
||||
"""Schema for recipe ingredient responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
recipe_id: UUID
|
||||
ingredient_id: UUID
|
||||
quantity: float
|
||||
unit: str
|
||||
quantity_in_base_unit: Optional[float] = None
|
||||
alternative_quantity: Optional[float] = None
|
||||
alternative_unit: Optional[str] = None
|
||||
preparation_method: Optional[str] = None
|
||||
ingredient_notes: Optional[str] = None
|
||||
is_optional: bool
|
||||
ingredient_order: int
|
||||
ingredient_group: Optional[str] = None
|
||||
substitution_options: Optional[Dict[str, Any]] = None
|
||||
substitution_ratio: Optional[float] = None
|
||||
unit_cost: Optional[float] = None
|
||||
total_cost: Optional[float] = None
|
||||
cost_updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RecipeCreate(BaseModel):
|
||||
"""Schema for creating recipes"""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
recipe_code: Optional[str] = Field(None, max_length=100)
|
||||
version: str = Field(default="1.0", max_length=20)
|
||||
finished_product_id: UUID
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
cuisine_type: Optional[str] = Field(None, max_length=100)
|
||||
difficulty_level: int = Field(default=1, ge=1, le=5)
|
||||
yield_quantity: float = Field(..., gt=0)
|
||||
yield_unit: MeasurementUnit
|
||||
prep_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
cook_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
total_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
rest_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
instructions: Optional[Dict[str, Any]] = None
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
serves_count: Optional[int] = Field(None, ge=1)
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
allergen_info: Optional[Dict[str, Any]] = None
|
||||
dietary_tags: Optional[Dict[str, Any]] = None
|
||||
batch_size_multiplier: float = Field(default=1.0, gt=0)
|
||||
minimum_batch_size: Optional[float] = Field(None, gt=0)
|
||||
maximum_batch_size: Optional[float] = Field(None, gt=0)
|
||||
optimal_production_temperature: Optional[float] = None
|
||||
optimal_humidity: Optional[float] = Field(None, ge=0, le=100)
|
||||
quality_check_points: Optional[Dict[str, Any]] = None
|
||||
common_issues: Optional[Dict[str, Any]] = None
|
||||
is_seasonal: bool = False
|
||||
season_start_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
season_end_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
is_signature_item: bool = False
|
||||
target_margin_percentage: Optional[float] = Field(None, ge=0)
|
||||
ingredients: List[RecipeIngredientCreate] = Field(..., min_items=1)
|
||||
|
||||
|
||||
class RecipeUpdate(BaseModel):
|
||||
"""Schema for updating recipes"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
recipe_code: Optional[str] = Field(None, max_length=100)
|
||||
version: Optional[str] = Field(None, max_length=20)
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
cuisine_type: Optional[str] = Field(None, max_length=100)
|
||||
difficulty_level: Optional[int] = Field(None, ge=1, le=5)
|
||||
yield_quantity: Optional[float] = Field(None, gt=0)
|
||||
yield_unit: Optional[MeasurementUnit] = None
|
||||
prep_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
cook_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
total_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
rest_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
instructions: Optional[Dict[str, Any]] = None
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
serves_count: Optional[int] = Field(None, ge=1)
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
allergen_info: Optional[Dict[str, Any]] = None
|
||||
dietary_tags: Optional[Dict[str, Any]] = None
|
||||
batch_size_multiplier: Optional[float] = Field(None, gt=0)
|
||||
minimum_batch_size: Optional[float] = Field(None, gt=0)
|
||||
maximum_batch_size: Optional[float] = Field(None, gt=0)
|
||||
optimal_production_temperature: Optional[float] = None
|
||||
optimal_humidity: Optional[float] = Field(None, ge=0, le=100)
|
||||
quality_check_points: Optional[Dict[str, Any]] = None
|
||||
common_issues: Optional[Dict[str, Any]] = None
|
||||
status: Optional[RecipeStatus] = None
|
||||
is_seasonal: Optional[bool] = None
|
||||
season_start_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
season_end_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
is_signature_item: Optional[bool] = None
|
||||
target_margin_percentage: Optional[float] = Field(None, ge=0)
|
||||
ingredients: Optional[List[RecipeIngredientCreate]] = None
|
||||
|
||||
|
||||
class RecipeResponse(BaseModel):
|
||||
"""Schema for recipe responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
name: str
|
||||
recipe_code: Optional[str] = None
|
||||
version: str
|
||||
finished_product_id: UUID
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
cuisine_type: Optional[str] = None
|
||||
difficulty_level: int
|
||||
yield_quantity: float
|
||||
yield_unit: str
|
||||
prep_time_minutes: Optional[int] = None
|
||||
cook_time_minutes: Optional[int] = None
|
||||
total_time_minutes: Optional[int] = None
|
||||
rest_time_minutes: Optional[int] = None
|
||||
estimated_cost_per_unit: Optional[float] = None
|
||||
last_calculated_cost: Optional[float] = None
|
||||
cost_calculation_date: Optional[datetime] = None
|
||||
target_margin_percentage: Optional[float] = None
|
||||
suggested_selling_price: Optional[float] = None
|
||||
instructions: Optional[Dict[str, Any]] = None
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
serves_count: Optional[int] = None
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
allergen_info: Optional[Dict[str, Any]] = None
|
||||
dietary_tags: Optional[Dict[str, Any]] = None
|
||||
batch_size_multiplier: float
|
||||
minimum_batch_size: Optional[float] = None
|
||||
maximum_batch_size: Optional[float] = None
|
||||
optimal_production_temperature: Optional[float] = None
|
||||
optimal_humidity: Optional[float] = None
|
||||
quality_check_points: Optional[Dict[str, Any]] = None
|
||||
common_issues: Optional[Dict[str, Any]] = None
|
||||
status: str
|
||||
is_seasonal: bool
|
||||
season_start_month: Optional[int] = None
|
||||
season_end_month: Optional[int] = None
|
||||
is_signature_item: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[UUID] = None
|
||||
updated_by: Optional[UUID] = None
|
||||
ingredients: Optional[List[RecipeIngredientResponse]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RecipeSearchRequest(BaseModel):
|
||||
"""Schema for recipe search requests"""
|
||||
search_term: Optional[str] = None
|
||||
status: Optional[RecipeStatus] = None
|
||||
category: Optional[str] = None
|
||||
is_seasonal: Optional[bool] = None
|
||||
is_signature: Optional[bool] = None
|
||||
difficulty_level: Optional[int] = Field(None, ge=1, le=5)
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class RecipeDuplicateRequest(BaseModel):
|
||||
"""Schema for recipe duplication requests"""
|
||||
new_name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecipeFeasibilityResponse(BaseModel):
|
||||
"""Schema for recipe feasibility check responses"""
|
||||
recipe_id: UUID
|
||||
recipe_name: str
|
||||
batch_multiplier: float
|
||||
feasible: bool
|
||||
missing_ingredients: List[Dict[str, Any]] = []
|
||||
insufficient_ingredients: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
class RecipeStatisticsResponse(BaseModel):
|
||||
"""Schema for recipe statistics responses"""
|
||||
total_recipes: int
|
||||
active_recipes: int
|
||||
signature_recipes: int
|
||||
seasonal_recipes: int
|
||||
category_breakdown: List[Dict[str, Any]]
|
||||
11
services/recipes/app/services/__init__.py
Normal file
11
services/recipes/app/services/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# services/recipes/app/services/__init__.py
|
||||
|
||||
from .recipe_service import RecipeService
|
||||
from .production_service import ProductionService
|
||||
from .inventory_client import InventoryClient
|
||||
|
||||
__all__ = [
|
||||
"RecipeService",
|
||||
"ProductionService",
|
||||
"InventoryClient"
|
||||
]
|
||||
184
services/recipes/app/services/inventory_client.py
Normal file
184
services/recipes/app/services/inventory_client.py
Normal file
@@ -0,0 +1,184 @@
|
||||
# services/recipes/app/services/inventory_client.py
|
||||
"""
|
||||
Client for communicating with Inventory Service
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InventoryClient:
|
||||
"""Client for inventory service communication"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.INVENTORY_SERVICE_URL
|
||||
self.timeout = 30.0
|
||||
|
||||
async def get_ingredient_by_id(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get ingredient details from inventory service"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/v1/ingredients/{ingredient_id}",
|
||||
headers={"X-Tenant-ID": str(tenant_id)}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 404:
|
||||
return None
|
||||
else:
|
||||
logger.error(f"Failed to get ingredient {ingredient_id}: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ingredient {ingredient_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_ingredients_by_ids(self, tenant_id: UUID, ingredient_ids: List[UUID]) -> List[Dict[str, Any]]:
|
||||
"""Get multiple ingredients by IDs"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/ingredients/batch",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json={"ingredient_ids": [str(id) for id in ingredient_ids]}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(f"Failed to get ingredients batch: {response.status_code}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ingredients batch: {e}")
|
||||
return []
|
||||
|
||||
async def get_ingredient_stock_level(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get current stock level for ingredient"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/v1/stock/ingredient/{ingredient_id}",
|
||||
headers={"X-Tenant-ID": str(tenant_id)}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 404:
|
||||
return None
|
||||
else:
|
||||
logger.error(f"Failed to get stock level for {ingredient_id}: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stock level for {ingredient_id}: {e}")
|
||||
return None
|
||||
|
||||
async def reserve_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
reservations: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Reserve ingredients for production"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/stock/reserve",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json={"reservations": reservations}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
logger.error(f"Failed to reserve ingredients: {response.status_code}")
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reserving ingredients: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def consume_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
consumptions: List[Dict[str, Any]],
|
||||
production_batch_id: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Record ingredient consumption for production"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/stock/consume",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json={
|
||||
"consumptions": consumptions,
|
||||
"reference_number": str(production_batch_id),
|
||||
"movement_type": "production_use"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
logger.error(f"Failed to consume ingredients: {response.status_code}")
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error consuming ingredients: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def add_finished_product_to_inventory(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
product_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Add finished product to inventory after production"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/stock/add",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json=product_data
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
logger.error(f"Failed to add finished product: {response.status_code}")
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding finished product: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def check_ingredient_availability(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
required_ingredients: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Check if required ingredients are available for production"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/stock/check-availability",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json={"required_ingredients": required_ingredients}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
logger.error(f"Failed to check availability: {response.status_code}")
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking availability: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
401
services/recipes/app/services/production_service.py
Normal file
401
services/recipes/app/services/production_service.py
Normal file
@@ -0,0 +1,401 @@
|
||||
# services/recipes/app/services/production_service.py
|
||||
"""
|
||||
Service layer for production management operations
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..repositories.production_repository import (
|
||||
ProductionRepository,
|
||||
ProductionIngredientConsumptionRepository,
|
||||
ProductionScheduleRepository
|
||||
)
|
||||
from ..repositories.recipe_repository import RecipeRepository
|
||||
from ..models.recipes import ProductionBatch, ProductionStatus, ProductionPriority
|
||||
from .inventory_client import InventoryClient
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductionService:
|
||||
"""Service for production management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.production_repo = ProductionRepository(db)
|
||||
self.consumption_repo = ProductionIngredientConsumptionRepository(db)
|
||||
self.schedule_repo = ProductionScheduleRepository(db)
|
||||
self.recipe_repo = RecipeRepository(db)
|
||||
self.inventory_client = InventoryClient()
|
||||
|
||||
async def create_production_batch(
|
||||
self,
|
||||
batch_data: Dict[str, Any],
|
||||
created_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new production batch"""
|
||||
try:
|
||||
# Validate recipe exists and is active
|
||||
recipe = self.recipe_repo.get_by_id(batch_data["recipe_id"])
|
||||
if not recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
if recipe.tenant_id != batch_data["tenant_id"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe does not belong to this tenant"
|
||||
}
|
||||
|
||||
# Check recipe feasibility if needed
|
||||
if batch_data.get("check_feasibility", True):
|
||||
from .recipe_service import RecipeService
|
||||
recipe_service = RecipeService(self.db)
|
||||
feasibility = await recipe_service.check_recipe_feasibility(
|
||||
recipe.id,
|
||||
batch_data.get("batch_size_multiplier", 1.0)
|
||||
)
|
||||
|
||||
if feasibility["success"] and not feasibility["data"]["feasible"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Insufficient ingredients available for production",
|
||||
"details": feasibility["data"]
|
||||
}
|
||||
|
||||
# Generate batch number if not provided
|
||||
if not batch_data.get("batch_number"):
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
count = self.production_repo.count_by_tenant(batch_data["tenant_id"])
|
||||
batch_data["batch_number"] = f"BATCH-{date_str}-{count + 1:04d}"
|
||||
|
||||
# Set defaults
|
||||
batch_data["created_by"] = created_by
|
||||
batch_data["status"] = ProductionStatus.PLANNED
|
||||
|
||||
batch = self.production_repo.create(batch_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production batch: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def start_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
ingredient_consumptions: List[Dict[str, Any]],
|
||||
staff_member: UUID,
|
||||
notes: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Start production batch and record ingredient consumptions"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
if batch.status != ProductionStatus.PLANNED:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cannot start batch in {batch.status.value} status"
|
||||
}
|
||||
|
||||
# Reserve ingredients in inventory
|
||||
reservations = []
|
||||
for consumption in ingredient_consumptions:
|
||||
reservations.append({
|
||||
"ingredient_id": str(consumption["ingredient_id"]),
|
||||
"quantity": consumption["actual_quantity"],
|
||||
"unit": consumption["unit"].value if hasattr(consumption["unit"], "value") else consumption["unit"],
|
||||
"reference": str(batch_id)
|
||||
})
|
||||
|
||||
reserve_result = await self.inventory_client.reserve_ingredients(
|
||||
batch.tenant_id,
|
||||
reservations
|
||||
)
|
||||
|
||||
if not reserve_result["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to reserve ingredients: {reserve_result['error']}"
|
||||
}
|
||||
|
||||
# Update batch status
|
||||
self.production_repo.update_batch_status(
|
||||
batch_id,
|
||||
ProductionStatus.IN_PROGRESS,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
# Record ingredient consumptions
|
||||
for consumption_data in ingredient_consumptions:
|
||||
consumption_data["tenant_id"] = batch.tenant_id
|
||||
consumption_data["production_batch_id"] = batch_id
|
||||
consumption_data["staff_member"] = staff_member
|
||||
consumption_data["consumption_time"] = datetime.utcnow()
|
||||
|
||||
# Calculate variance
|
||||
planned = consumption_data["planned_quantity"]
|
||||
actual = consumption_data["actual_quantity"]
|
||||
consumption_data["variance_quantity"] = actual - planned
|
||||
if planned > 0:
|
||||
consumption_data["variance_percentage"] = ((actual - planned) / planned) * 100
|
||||
|
||||
self.consumption_repo.create(consumption_data)
|
||||
|
||||
# Get updated batch
|
||||
updated_batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def complete_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
completion_data: Dict[str, Any],
|
||||
completed_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Complete production batch and add finished products to inventory"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
if batch.status != ProductionStatus.IN_PROGRESS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cannot complete batch in {batch.status.value} status"
|
||||
}
|
||||
|
||||
# Calculate yield percentage
|
||||
actual_quantity = completion_data["actual_quantity"]
|
||||
yield_percentage = (actual_quantity / batch.planned_quantity) * 100
|
||||
|
||||
# Calculate efficiency percentage
|
||||
efficiency_percentage = None
|
||||
if batch.actual_start_time and batch.planned_start_time and batch.planned_end_time:
|
||||
planned_duration = (batch.planned_end_time - batch.planned_start_time).total_seconds()
|
||||
actual_duration = (datetime.utcnow() - batch.actual_start_time).total_seconds()
|
||||
if actual_duration > 0:
|
||||
efficiency_percentage = (planned_duration / actual_duration) * 100
|
||||
|
||||
# Update batch with completion data
|
||||
update_data = {
|
||||
"actual_quantity": actual_quantity,
|
||||
"yield_percentage": yield_percentage,
|
||||
"efficiency_percentage": efficiency_percentage,
|
||||
"actual_end_time": datetime.utcnow(),
|
||||
"completed_by": completed_by,
|
||||
"status": ProductionStatus.COMPLETED,
|
||||
**{k: v for k, v in completion_data.items() if k != "actual_quantity"}
|
||||
}
|
||||
|
||||
updated_batch = self.production_repo.update(batch_id, update_data)
|
||||
|
||||
# Add finished products to inventory
|
||||
recipe = self.recipe_repo.get_by_id(batch.recipe_id)
|
||||
if recipe:
|
||||
product_data = {
|
||||
"ingredient_id": str(recipe.finished_product_id),
|
||||
"quantity": actual_quantity,
|
||||
"batch_number": batch.batch_number,
|
||||
"production_date": batch.production_date.isoformat(),
|
||||
"reference_number": str(batch_id),
|
||||
"movement_type": "production",
|
||||
"notes": f"Production batch {batch.batch_number}"
|
||||
}
|
||||
|
||||
inventory_result = await self.inventory_client.add_finished_product_to_inventory(
|
||||
batch.tenant_id,
|
||||
product_data
|
||||
)
|
||||
|
||||
if not inventory_result["success"]:
|
||||
logger.warning(f"Failed to add finished product to inventory: {inventory_result['error']}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error completing production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_production_batch_with_consumptions(self, batch_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get production batch with all consumption records"""
|
||||
batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
if not batch:
|
||||
return None
|
||||
|
||||
batch_dict = batch.to_dict()
|
||||
batch_dict["ingredient_consumptions"] = [
|
||||
cons.to_dict() for cons in batch.ingredient_consumptions
|
||||
]
|
||||
|
||||
return batch_dict
|
||||
|
||||
def search_production_batches(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
recipe_id: Optional[UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search production batches with filters"""
|
||||
production_status = ProductionStatus(status) if status else None
|
||||
production_priority = ProductionPriority(priority) if priority else None
|
||||
|
||||
batches = self.production_repo.search_batches(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=production_status,
|
||||
priority=production_priority,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
recipe_id=recipe_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return [batch.to_dict() for batch in batches]
|
||||
|
||||
def get_active_production_batches(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Get all active production batches"""
|
||||
batches = self.production_repo.get_active_batches(tenant_id)
|
||||
return [batch.to_dict() for batch in batches]
|
||||
|
||||
def get_production_statistics(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get production statistics for dashboard"""
|
||||
return self.production_repo.get_production_statistics(tenant_id, start_date, end_date)
|
||||
|
||||
async def update_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
update_data: Dict[str, Any],
|
||||
updated_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Update production batch"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
# Add audit info
|
||||
update_data["updated_by"] = updated_by
|
||||
|
||||
updated_batch = self.production_repo.update(batch_id, update_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# Production Schedule methods
|
||||
def create_production_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new production schedule"""
|
||||
try:
|
||||
schedule = self.schedule_repo.create(schedule_data)
|
||||
return {
|
||||
"success": True,
|
||||
"data": schedule.to_dict()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production schedule: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_production_schedule(self, schedule_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get production schedule by ID"""
|
||||
schedule = self.schedule_repo.get_by_id(schedule_id)
|
||||
return schedule.to_dict() if schedule else None
|
||||
|
||||
def get_production_schedule_by_date(self, tenant_id: UUID, schedule_date: date) -> Optional[Dict[str, Any]]:
|
||||
"""Get production schedule for specific date"""
|
||||
schedule = self.schedule_repo.get_by_date(tenant_id, schedule_date)
|
||||
return schedule.to_dict() if schedule else None
|
||||
|
||||
def get_published_schedules(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get published schedules within date range"""
|
||||
schedules = self.schedule_repo.get_published_schedules(tenant_id, start_date, end_date)
|
||||
return [schedule.to_dict() for schedule in schedules]
|
||||
|
||||
def get_production_schedules_range(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get all schedules within date range"""
|
||||
if not start_date:
|
||||
start_date = date.today()
|
||||
if not end_date:
|
||||
from datetime import timedelta
|
||||
end_date = start_date + timedelta(days=7)
|
||||
|
||||
schedules = self.schedule_repo.get_published_schedules(tenant_id, start_date, end_date)
|
||||
return [schedule.to_dict() for schedule in schedules]
|
||||
374
services/recipes/app/services/recipe_service.py
Normal file
374
services/recipes/app/services/recipe_service.py
Normal file
@@ -0,0 +1,374 @@
|
||||
# services/recipes/app/services/recipe_service.py
|
||||
"""
|
||||
Service layer for recipe management operations
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..repositories.recipe_repository import RecipeRepository, RecipeIngredientRepository
|
||||
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
|
||||
from .inventory_client import InventoryClient
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecipeService:
|
||||
"""Service for recipe management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.recipe_repo = RecipeRepository(db)
|
||||
self.ingredient_repo = RecipeIngredientRepository(db)
|
||||
self.inventory_client = InventoryClient()
|
||||
|
||||
async def create_recipe(
|
||||
self,
|
||||
recipe_data: Dict[str, Any],
|
||||
ingredients_data: List[Dict[str, Any]],
|
||||
created_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new recipe with ingredients"""
|
||||
try:
|
||||
# Validate finished product exists in inventory
|
||||
finished_product = await self.inventory_client.get_ingredient_by_id(
|
||||
recipe_data["tenant_id"],
|
||||
recipe_data["finished_product_id"]
|
||||
)
|
||||
|
||||
if not finished_product:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Finished product not found in inventory"
|
||||
}
|
||||
|
||||
if finished_product.get("product_type") != "finished_product":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Referenced item is not a finished product"
|
||||
}
|
||||
|
||||
# Validate ingredients exist in inventory
|
||||
ingredient_ids = [UUID(ing["ingredient_id"]) for ing in ingredients_data]
|
||||
ingredients = await self.inventory_client.get_ingredients_by_ids(
|
||||
recipe_data["tenant_id"],
|
||||
ingredient_ids
|
||||
)
|
||||
|
||||
if len(ingredients) != len(ingredient_ids):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Some ingredients not found in inventory"
|
||||
}
|
||||
|
||||
# Create recipe
|
||||
recipe_data["created_by"] = created_by
|
||||
recipe = self.recipe_repo.create(recipe_data)
|
||||
|
||||
# Create recipe ingredients
|
||||
for ingredient_data in ingredients_data:
|
||||
ingredient_data["tenant_id"] = recipe_data["tenant_id"]
|
||||
ingredient_data["recipe_id"] = recipe.id
|
||||
|
||||
# Calculate cost if available
|
||||
inventory_ingredient = next(
|
||||
(ing for ing in ingredients if ing["id"] == ingredient_data["ingredient_id"]),
|
||||
None
|
||||
)
|
||||
if inventory_ingredient and inventory_ingredient.get("average_cost"):
|
||||
unit_cost = float(inventory_ingredient["average_cost"])
|
||||
total_cost = unit_cost * ingredient_data["quantity"]
|
||||
ingredient_data["unit_cost"] = unit_cost
|
||||
ingredient_data["total_cost"] = total_cost
|
||||
ingredient_data["cost_updated_at"] = datetime.utcnow()
|
||||
|
||||
self.ingredient_repo.create(ingredient_data)
|
||||
|
||||
# Calculate and update recipe cost
|
||||
await self._update_recipe_cost(recipe.id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": recipe.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating recipe: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def update_recipe(
|
||||
self,
|
||||
recipe_id: UUID,
|
||||
recipe_data: Dict[str, Any],
|
||||
ingredients_data: Optional[List[Dict[str, Any]]] = None,
|
||||
updated_by: UUID = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing recipe"""
|
||||
try:
|
||||
recipe = self.recipe_repo.get_by_id(recipe_id)
|
||||
if not recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
# Update recipe data
|
||||
if updated_by:
|
||||
recipe_data["updated_by"] = updated_by
|
||||
|
||||
updated_recipe = self.recipe_repo.update(recipe_id, recipe_data)
|
||||
|
||||
# Update ingredients if provided
|
||||
if ingredients_data is not None:
|
||||
# Validate ingredients exist in inventory
|
||||
ingredient_ids = [UUID(ing["ingredient_id"]) for ing in ingredients_data]
|
||||
ingredients = await self.inventory_client.get_ingredients_by_ids(
|
||||
recipe.tenant_id,
|
||||
ingredient_ids
|
||||
)
|
||||
|
||||
if len(ingredients) != len(ingredient_ids):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Some ingredients not found in inventory"
|
||||
}
|
||||
|
||||
# Update ingredients
|
||||
for ingredient_data in ingredients_data:
|
||||
ingredient_data["tenant_id"] = recipe.tenant_id
|
||||
|
||||
# Calculate cost if available
|
||||
inventory_ingredient = next(
|
||||
(ing for ing in ingredients if ing["id"] == ingredient_data["ingredient_id"]),
|
||||
None
|
||||
)
|
||||
if inventory_ingredient and inventory_ingredient.get("average_cost"):
|
||||
unit_cost = float(inventory_ingredient["average_cost"])
|
||||
total_cost = unit_cost * ingredient_data["quantity"]
|
||||
ingredient_data["unit_cost"] = unit_cost
|
||||
ingredient_data["total_cost"] = total_cost
|
||||
ingredient_data["cost_updated_at"] = datetime.utcnow()
|
||||
|
||||
self.ingredient_repo.update_ingredients_for_recipe(recipe_id, ingredients_data)
|
||||
|
||||
# Recalculate recipe cost
|
||||
await self._update_recipe_cost(recipe_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_recipe.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe {recipe_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get recipe with all ingredients"""
|
||||
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
|
||||
if not recipe:
|
||||
return None
|
||||
|
||||
recipe_dict = recipe.to_dict()
|
||||
recipe_dict["ingredients"] = [ing.to_dict() for ing in recipe.ingredients]
|
||||
|
||||
return recipe_dict
|
||||
|
||||
def search_recipes(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
is_seasonal: Optional[bool] = None,
|
||||
is_signature: Optional[bool] = None,
|
||||
difficulty_level: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search recipes with filters"""
|
||||
recipe_status = RecipeStatus(status) if status else None
|
||||
|
||||
recipes = self.recipe_repo.search_recipes(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=recipe_status,
|
||||
category=category,
|
||||
is_seasonal=is_seasonal,
|
||||
is_signature=is_signature,
|
||||
difficulty_level=difficulty_level,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return [recipe.to_dict() for recipe in recipes]
|
||||
|
||||
def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get recipe statistics for dashboard"""
|
||||
return self.recipe_repo.get_recipe_statistics(tenant_id)
|
||||
|
||||
async def check_recipe_feasibility(self, recipe_id: UUID, batch_multiplier: float = 1.0) -> Dict[str, Any]:
|
||||
"""Check if recipe can be produced with current inventory"""
|
||||
try:
|
||||
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
|
||||
if not recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
# Calculate required ingredients
|
||||
required_ingredients = []
|
||||
for ingredient in recipe.ingredients:
|
||||
required_quantity = ingredient.quantity * batch_multiplier
|
||||
required_ingredients.append({
|
||||
"ingredient_id": str(ingredient.ingredient_id),
|
||||
"required_quantity": required_quantity,
|
||||
"unit": ingredient.unit.value
|
||||
})
|
||||
|
||||
# Check availability with inventory service
|
||||
availability_check = await self.inventory_client.check_ingredient_availability(
|
||||
recipe.tenant_id,
|
||||
required_ingredients
|
||||
)
|
||||
|
||||
if not availability_check["success"]:
|
||||
return availability_check
|
||||
|
||||
availability_data = availability_check["data"]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"recipe_id": str(recipe_id),
|
||||
"recipe_name": recipe.name,
|
||||
"batch_multiplier": batch_multiplier,
|
||||
"feasible": availability_data.get("all_available", False),
|
||||
"missing_ingredients": availability_data.get("missing_ingredients", []),
|
||||
"insufficient_ingredients": availability_data.get("insufficient_ingredients", [])
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking recipe feasibility {recipe_id}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def duplicate_recipe(
|
||||
self,
|
||||
recipe_id: UUID,
|
||||
new_name: str,
|
||||
created_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a duplicate of an existing recipe"""
|
||||
try:
|
||||
new_recipe = self.recipe_repo.duplicate_recipe(recipe_id, new_name, created_by)
|
||||
if not new_recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": new_recipe.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error duplicating recipe {recipe_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def activate_recipe(self, recipe_id: UUID, activated_by: UUID) -> Dict[str, Any]:
|
||||
"""Activate a recipe for production"""
|
||||
try:
|
||||
# Check if recipe is complete and valid
|
||||
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
|
||||
if not recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
if not recipe.ingredients:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe must have at least one ingredient"
|
||||
}
|
||||
|
||||
# Validate all ingredients exist in inventory
|
||||
ingredient_ids = [ing.ingredient_id for ing in recipe.ingredients]
|
||||
ingredients = await self.inventory_client.get_ingredients_by_ids(
|
||||
recipe.tenant_id,
|
||||
ingredient_ids
|
||||
)
|
||||
|
||||
if len(ingredients) != len(ingredient_ids):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Some recipe ingredients not found in inventory"
|
||||
}
|
||||
|
||||
# Update recipe status
|
||||
updated_recipe = self.recipe_repo.update(recipe_id, {
|
||||
"status": RecipeStatus.ACTIVE,
|
||||
"updated_by": activated_by
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_recipe.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error activating recipe {recipe_id}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def _update_recipe_cost(self, recipe_id: UUID) -> None:
|
||||
"""Update recipe cost based on ingredient costs"""
|
||||
try:
|
||||
total_cost = self.ingredient_repo.calculate_recipe_cost(recipe_id)
|
||||
|
||||
recipe = self.recipe_repo.get_by_id(recipe_id)
|
||||
if recipe:
|
||||
cost_per_unit = total_cost / recipe.yield_quantity if recipe.yield_quantity > 0 else 0
|
||||
|
||||
# Add overhead
|
||||
overhead_cost = cost_per_unit * (settings.OVERHEAD_PERCENTAGE / 100)
|
||||
total_cost_with_overhead = cost_per_unit + overhead_cost
|
||||
|
||||
# Calculate suggested selling price with target margin
|
||||
if recipe.target_margin_percentage:
|
||||
suggested_price = total_cost_with_overhead * (1 + recipe.target_margin_percentage / 100)
|
||||
else:
|
||||
suggested_price = total_cost_with_overhead * 1.3 # Default 30% margin
|
||||
|
||||
self.recipe_repo.update(recipe_id, {
|
||||
"last_calculated_cost": total_cost_with_overhead,
|
||||
"cost_calculation_date": datetime.utcnow(),
|
||||
"suggested_selling_price": suggested_price
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe cost for {recipe_id}: {e}")
|
||||
Reference in New Issue
Block a user