2025-08-13 17:39:35 +02:00
|
|
|
# services/inventory/app/api/ingredients.py
|
|
|
|
|
"""
|
2025-10-06 15:27:01 +02:00
|
|
|
Base CRUD operations for inventory ingredients resources
|
|
|
|
|
Following standardized URL structure: /api/v1/tenants/{tenant_id}/inventory/{resource}
|
2025-08-13 17:39:35 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
from uuid import UUID
|
2025-08-14 13:26:59 +02:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
2025-08-13 17:39:35 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2025-12-18 13:26:32 +01:00
|
|
|
import httpx
|
|
|
|
|
import structlog
|
2025-08-13 17:39:35 +02:00
|
|
|
|
|
|
|
|
from app.core.database import get_db
|
|
|
|
|
from app.services.inventory_service import InventoryService
|
2025-10-29 06:58:05 +01:00
|
|
|
from app.models import AuditLog
|
2025-08-13 17:39:35 +02:00
|
|
|
from app.schemas.inventory import (
|
2025-09-17 16:06:30 +02:00
|
|
|
IngredientCreate,
|
|
|
|
|
IngredientUpdate,
|
2025-08-13 17:39:35 +02:00
|
|
|
IngredientResponse,
|
2025-09-17 16:06:30 +02:00
|
|
|
StockResponse,
|
2025-10-06 15:27:01 +02:00
|
|
|
StockCreate,
|
|
|
|
|
StockUpdate,
|
2025-12-25 18:35:37 +01:00
|
|
|
BulkIngredientCreate,
|
|
|
|
|
BulkIngredientResponse,
|
|
|
|
|
BulkIngredientResult,
|
2025-08-13 17:39:35 +02:00
|
|
|
)
|
2025-09-04 23:19:53 +02:00
|
|
|
from shared.auth.decorators import get_current_user_dep
|
2025-10-06 15:27:01 +02:00
|
|
|
from shared.auth.access_control import require_user_role, admin_role_required, owner_role_required
|
|
|
|
|
from shared.routing import RouteBuilder
|
2025-10-15 16:12:49 +02:00
|
|
|
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-12-18 13:26:32 +01:00
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
# Create route builder for consistent URL structure
|
|
|
|
|
route_builder = RouteBuilder('inventory')
|
2025-08-13 17:39:35 +02:00
|
|
|
|
2025-08-14 13:26:59 +02:00
|
|
|
router = APIRouter(tags=["ingredients"])
|
2025-08-13 17:39:35 +02:00
|
|
|
|
2025-10-15 16:12:49 +02:00
|
|
|
# Initialize audit logger
|
2025-10-29 06:58:05 +01:00
|
|
|
audit_logger = create_audit_logger("inventory-service", AuditLog)
|
2025-10-15 16:12:49 +02:00
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
# Helper function to extract user ID from user object
|
|
|
|
|
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
|
|
|
|
"""Extract user ID from current user context"""
|
|
|
|
|
user_id = current_user.get('user_id')
|
|
|
|
|
if not user_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="User ID not found in context"
|
|
|
|
|
)
|
|
|
|
|
return UUID(user_id)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
# ===== INGREDIENTS ENDPOINTS =====
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
route_builder.build_base_route("ingredients"),
|
|
|
|
|
response_model=IngredientResponse,
|
|
|
|
|
status_code=status.HTTP_201_CREATED
|
|
|
|
|
)
|
|
|
|
|
@require_user_role(['admin', 'owner'])
|
2025-08-13 17:39:35 +02:00
|
|
|
async def create_ingredient(
|
|
|
|
|
ingredient_data: IngredientCreate,
|
2025-08-14 13:26:59 +02:00
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-08-13 17:39:35 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2025-10-06 15:27:01 +02:00
|
|
|
"""Create a new ingredient (Admin/Manager only)"""
|
2025-08-13 17:39:35 +02:00
|
|
|
try:
|
2025-12-18 13:26:32 +01:00
|
|
|
# CRITICAL: Check subscription limit before creating
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
|
|
|
try:
|
|
|
|
|
limit_check_response = await client.get(
|
2026-01-16 15:19:34 +01:00
|
|
|
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/subscription/limits/products",
|
2025-12-18 13:26:32 +01:00
|
|
|
headers={
|
|
|
|
|
"x-user-id": str(current_user.get('user_id')),
|
|
|
|
|
"x-tenant-id": str(tenant_id)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if limit_check_response.status_code == 200:
|
|
|
|
|
limit_check = limit_check_response.json()
|
|
|
|
|
|
|
|
|
|
if not limit_check.get('can_add', False):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Product limit exceeded",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
current=limit_check.get('current_count'),
|
|
|
|
|
max=limit_check.get('max_allowed'),
|
|
|
|
|
reason=limit_check.get('reason')
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
|
|
|
detail={
|
|
|
|
|
"error": "product_limit_exceeded",
|
|
|
|
|
"message": limit_check.get('reason', 'Product limit exceeded'),
|
|
|
|
|
"current_count": limit_check.get('current_count'),
|
|
|
|
|
"max_allowed": limit_check.get('max_allowed'),
|
|
|
|
|
"upgrade_required": True
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Failed to check product limit, allowing creation",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
status_code=limit_check_response.status_code
|
|
|
|
|
)
|
|
|
|
|
except httpx.TimeoutException:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Timeout checking product limit, allowing creation",
|
|
|
|
|
tenant_id=str(tenant_id)
|
|
|
|
|
)
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Error checking product limit, allowing creation",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e)
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
# Extract user ID - handle service tokens
|
2025-08-14 13:26:59 +02:00
|
|
|
raw_user_id = current_user.get('user_id')
|
|
|
|
|
if current_user.get('type') == 'service':
|
|
|
|
|
user_id = None
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
user_id = UUID(raw_user_id)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
user_id = None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
service = InventoryService()
|
|
|
|
|
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
2025-12-18 13:26:32 +01:00
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Ingredient created successfully",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
ingredient_id=str(ingredient.id),
|
|
|
|
|
ingredient_name=ingredient.name
|
|
|
|
|
)
|
|
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
return ingredient
|
2025-12-18 13:26:32 +01:00
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2025-08-13 17:39:35 +02:00
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
2025-12-18 13:26:32 +01:00
|
|
|
logger.error(
|
|
|
|
|
"Failed to create ingredient",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e)
|
|
|
|
|
)
|
2025-08-13 17:39:35 +02:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to create ingredient"
|
2025-09-21 13:27:50 +02:00
|
|
|
)
|
2025-12-25 18:35:37 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
route_builder.build_base_route("ingredients/bulk"),
|
|
|
|
|
response_model=BulkIngredientResponse,
|
|
|
|
|
status_code=status.HTTP_201_CREATED
|
|
|
|
|
)
|
|
|
|
|
@require_user_role(['admin', 'owner'])
|
|
|
|
|
async def bulk_create_ingredients(
|
|
|
|
|
bulk_data: BulkIngredientCreate,
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Create multiple ingredients in a single transaction (Admin/Manager only)"""
|
|
|
|
|
import uuid
|
|
|
|
|
transaction_id = str(uuid.uuid4())
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# CRITICAL: Check subscription limit ONCE before creating any ingredients
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
total_requested = len(bulk_data.ingredients)
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
|
|
|
try:
|
|
|
|
|
# Check if we can add this many products
|
|
|
|
|
limit_check_response = await client.get(
|
2026-01-16 15:19:34 +01:00
|
|
|
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/subscription/limits/products",
|
2025-12-25 18:35:37 +01:00
|
|
|
headers={
|
|
|
|
|
"x-user-id": str(current_user.get('user_id')),
|
|
|
|
|
"x-tenant-id": str(tenant_id)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if limit_check_response.status_code == 200:
|
|
|
|
|
limit_check = limit_check_response.json()
|
|
|
|
|
|
|
|
|
|
if not limit_check.get('can_add', False):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Bulk product limit exceeded",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
requested=total_requested,
|
|
|
|
|
current=limit_check.get('current_count'),
|
|
|
|
|
max=limit_check.get('max_allowed'),
|
|
|
|
|
reason=limit_check.get('reason')
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
|
|
|
detail={
|
|
|
|
|
"error": "product_limit_exceeded",
|
|
|
|
|
"message": limit_check.get('reason', 'Product limit exceeded'),
|
|
|
|
|
"requested": total_requested,
|
|
|
|
|
"current_count": limit_check.get('current_count'),
|
|
|
|
|
"max_allowed": limit_check.get('max_allowed'),
|
|
|
|
|
"upgrade_required": True
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Failed to check product limit, allowing bulk creation",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
status_code=limit_check_response.status_code
|
|
|
|
|
)
|
|
|
|
|
except httpx.TimeoutException:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Timeout checking product limit, allowing bulk creation",
|
|
|
|
|
tenant_id=str(tenant_id)
|
|
|
|
|
)
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Error checking product limit, allowing bulk creation",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Extract user ID - handle service tokens
|
|
|
|
|
raw_user_id = current_user.get('user_id')
|
|
|
|
|
if current_user.get('type') == 'service':
|
|
|
|
|
user_id = None
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
user_id = UUID(raw_user_id)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
user_id = None
|
|
|
|
|
|
|
|
|
|
# Create all ingredients
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
results: List[BulkIngredientResult] = []
|
|
|
|
|
total_created = 0
|
|
|
|
|
total_failed = 0
|
|
|
|
|
|
|
|
|
|
for index, ingredient_data in enumerate(bulk_data.ingredients):
|
|
|
|
|
try:
|
|
|
|
|
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
|
|
|
|
results.append(BulkIngredientResult(
|
|
|
|
|
index=index,
|
|
|
|
|
success=True,
|
|
|
|
|
ingredient=IngredientResponse.from_orm(ingredient),
|
|
|
|
|
error=None
|
|
|
|
|
))
|
|
|
|
|
total_created += 1
|
|
|
|
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
"Ingredient created in bulk operation",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
ingredient_id=str(ingredient.id),
|
|
|
|
|
ingredient_name=ingredient.name,
|
|
|
|
|
index=index,
|
|
|
|
|
transaction_id=transaction_id
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
results.append(BulkIngredientResult(
|
|
|
|
|
index=index,
|
|
|
|
|
success=False,
|
|
|
|
|
ingredient=None,
|
|
|
|
|
error=str(e)
|
|
|
|
|
))
|
|
|
|
|
total_failed += 1
|
|
|
|
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Failed to create ingredient in bulk operation",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
index=index,
|
|
|
|
|
error=str(e),
|
|
|
|
|
transaction_id=transaction_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Bulk ingredient creation completed",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
total_requested=total_requested,
|
|
|
|
|
total_created=total_created,
|
|
|
|
|
total_failed=total_failed,
|
|
|
|
|
transaction_id=transaction_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return BulkIngredientResponse(
|
|
|
|
|
total_requested=total_requested,
|
|
|
|
|
total_created=total_created,
|
|
|
|
|
total_failed=total_failed,
|
|
|
|
|
results=results,
|
|
|
|
|
transaction_id=transaction_id
|
|
|
|
|
)
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Failed to process bulk ingredient creation",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e),
|
|
|
|
|
transaction_id=transaction_id
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to process bulk ingredient creation"
|
|
|
|
|
)
|
2025-09-21 13:27:50 +02:00
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_base_route("ingredients/count"),
|
|
|
|
|
response_model=dict
|
|
|
|
|
)
|
2025-09-21 13:27:50 +02:00
|
|
|
async def count_ingredients(
|
|
|
|
|
tenant_id: UUID = Path(...),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
2025-10-06 15:27:01 +02:00
|
|
|
):
|
|
|
|
|
"""Get count of ingredients for a tenant (All users)"""
|
2025-09-21 13:27:50 +02:00
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
count = await service.count_ingredients_by_tenant(tenant_id)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"tenant_id": str(tenant_id),
|
|
|
|
|
"ingredient_count": count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail=f"Failed to count ingredients: {str(e)}"
|
2025-08-13 17:39:35 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_resource_detail_route("ingredients", "ingredient_id"),
|
|
|
|
|
response_model=IngredientResponse
|
|
|
|
|
)
|
2025-08-13 17:39:35 +02:00
|
|
|
async def get_ingredient(
|
|
|
|
|
ingredient_id: UUID,
|
2025-08-14 13:26:59 +02:00
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-08-13 17:39:35 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2025-10-06 15:27:01 +02:00
|
|
|
"""Get ingredient by ID (All users)"""
|
2025-08-13 17:39:35 +02:00
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
ingredient = await service.get_ingredient(ingredient_id, tenant_id)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
if not ingredient:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Ingredient not found"
|
|
|
|
|
)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
return ingredient
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get ingredient"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.put(
|
|
|
|
|
route_builder.build_resource_detail_route("ingredients", "ingredient_id"),
|
|
|
|
|
response_model=IngredientResponse
|
|
|
|
|
)
|
|
|
|
|
@require_user_role(['admin', 'owner', 'member'])
|
2025-08-13 17:39:35 +02:00
|
|
|
async def update_ingredient(
|
|
|
|
|
ingredient_id: UUID,
|
|
|
|
|
ingredient_data: IngredientUpdate,
|
2025-08-14 13:26:59 +02:00
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-08-13 17:39:35 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2025-10-06 15:27:01 +02:00
|
|
|
"""Update ingredient (Admin/Manager/User)"""
|
|
|
|
|
try:
|
2025-08-13 17:39:35 +02:00
|
|
|
service = InventoryService()
|
|
|
|
|
ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
if not ingredient:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Ingredient not found"
|
|
|
|
|
)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
return ingredient
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to update ingredient"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_base_route("ingredients"),
|
|
|
|
|
response_model=List[IngredientResponse]
|
|
|
|
|
)
|
2025-08-13 17:39:35 +02:00
|
|
|
async def list_ingredients(
|
2025-08-14 13:26:59 +02:00
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-08-13 17:39:35 +02:00
|
|
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
|
|
|
|
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
|
|
|
|
category: Optional[str] = Query(None, description="Filter by category"),
|
2025-10-06 15:27:01 +02:00
|
|
|
product_type: Optional[str] = Query(None, description="Filter by product type"),
|
2025-08-13 17:39:35 +02:00
|
|
|
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
|
|
|
|
is_low_stock: Optional[bool] = Query(None, description="Filter by low stock status"),
|
|
|
|
|
needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"),
|
|
|
|
|
search: Optional[str] = Query(None, description="Search in name, SKU, or barcode"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-08-13 17:39:35 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2025-10-06 15:27:01 +02:00
|
|
|
"""List ingredients with filtering (All users)"""
|
2025-08-13 17:39:35 +02:00
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
# Build filters
|
|
|
|
|
filters = {}
|
|
|
|
|
if category:
|
|
|
|
|
filters['category'] = category
|
2025-08-16 08:22:51 +02:00
|
|
|
if product_type:
|
|
|
|
|
filters['product_type'] = product_type
|
2025-08-13 17:39:35 +02:00
|
|
|
if is_active is not None:
|
|
|
|
|
filters['is_active'] = is_active
|
|
|
|
|
if is_low_stock is not None:
|
|
|
|
|
filters['is_low_stock'] = is_low_stock
|
|
|
|
|
if needs_reorder is not None:
|
|
|
|
|
filters['needs_reorder'] = needs_reorder
|
|
|
|
|
if search:
|
|
|
|
|
filters['search'] = search
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
ingredients = await service.get_ingredients(tenant_id, skip, limit, filters)
|
|
|
|
|
return ingredients
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to list ingredients"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.delete(
|
|
|
|
|
route_builder.build_resource_detail_route("ingredients", "ingredient_id"),
|
|
|
|
|
status_code=status.HTTP_204_NO_CONTENT
|
|
|
|
|
)
|
|
|
|
|
@admin_role_required
|
2025-09-17 16:06:30 +02:00
|
|
|
async def soft_delete_ingredient(
|
2025-08-13 17:39:35 +02:00
|
|
|
ingredient_id: UUID,
|
2025-08-14 13:26:59 +02:00
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-08-13 17:39:35 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2025-10-06 15:27:01 +02:00
|
|
|
"""Soft delete ingredient - mark as inactive (Admin only)"""
|
2025-08-13 17:39:35 +02:00
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
2025-09-17 16:06:30 +02:00
|
|
|
result = await service.soft_delete_ingredient(ingredient_id, tenant_id)
|
2025-08-13 17:39:35 +02:00
|
|
|
return None
|
2025-09-17 16:06:30 +02:00
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to soft delete ingredient"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.delete(
|
|
|
|
|
route_builder.build_nested_resource_route("ingredients", "ingredient_id", "hard"),
|
|
|
|
|
response_model=dict
|
|
|
|
|
)
|
|
|
|
|
@admin_role_required
|
2025-09-17 16:06:30 +02:00
|
|
|
async def hard_delete_ingredient(
|
|
|
|
|
ingredient_id: UUID,
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-09-17 16:06:30 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2025-10-06 15:27:01 +02:00
|
|
|
"""Hard delete ingredient and all associated data (Admin only)"""
|
2025-09-17 16:06:30 +02:00
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
deletion_summary = await service.hard_delete_ingredient(ingredient_id, tenant_id)
|
2025-10-15 16:12:49 +02:00
|
|
|
|
|
|
|
|
# Log audit event for hard deletion
|
|
|
|
|
try:
|
|
|
|
|
await audit_logger.log_deletion(
|
|
|
|
|
db_session=db,
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
user_id=current_user["user_id"],
|
|
|
|
|
resource_type="ingredient",
|
|
|
|
|
resource_id=str(ingredient_id),
|
|
|
|
|
resource_data=deletion_summary,
|
|
|
|
|
description=f"Hard deleted ingredient and all associated data",
|
|
|
|
|
endpoint=f"/ingredients/{ingredient_id}/hard",
|
|
|
|
|
method="DELETE"
|
|
|
|
|
)
|
|
|
|
|
except Exception as audit_error:
|
|
|
|
|
import structlog
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
logger.warning("Failed to log audit event", error=str(audit_error))
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
return deletion_summary
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
2025-08-13 17:39:35 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
2025-09-17 16:06:30 +02:00
|
|
|
detail="Failed to hard delete ingredient"
|
2025-08-13 17:39:35 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_nested_resource_route("ingredients", "ingredient_id", "stock"),
|
|
|
|
|
response_model=List[StockResponse]
|
|
|
|
|
)
|
2025-08-13 17:39:35 +02:00
|
|
|
async def get_ingredient_stock(
|
|
|
|
|
ingredient_id: UUID,
|
2025-08-14 13:26:59 +02:00
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-08-13 17:39:35 +02:00
|
|
|
include_unavailable: bool = Query(False, description="Include unavailable stock"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-08-13 17:39:35 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2025-10-06 15:27:01 +02:00
|
|
|
"""Get stock entries for an ingredient (All users)"""
|
2025-08-13 17:39:35 +02:00
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
stock_entries = await service.get_stock_by_ingredient(
|
|
|
|
|
ingredient_id, tenant_id, include_unavailable
|
|
|
|
|
)
|
|
|
|
|
return stock_entries
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get ingredient stock"
|
2025-10-06 15:27:01 +02:00
|
|
|
)
|