222 lines
8.8 KiB
Python
222 lines
8.8 KiB
Python
|
|
# services/inventory/app/api/transformations.py
|
||
|
|
"""
|
||
|
|
API endpoints for product transformations
|
||
|
|
Following standardized URL structure with role-based access control
|
||
|
|
"""
|
||
|
|
|
||
|
|
from typing import List, Optional
|
||
|
|
from uuid import UUID
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
import structlog
|
||
|
|
|
||
|
|
from app.core.database import get_db
|
||
|
|
from app.services.transformation_service import TransformationService
|
||
|
|
from app.schemas.inventory import (
|
||
|
|
ProductTransformationCreate,
|
||
|
|
ProductTransformationResponse
|
||
|
|
)
|
||
|
|
from app.models.inventory import ProductionStage
|
||
|
|
from shared.auth.decorators import get_current_user_dep
|
||
|
|
from shared.auth.access_control import require_user_role, admin_role_required
|
||
|
|
from shared.routing import RouteBuilder
|
||
|
|
|
||
|
|
logger = structlog.get_logger()
|
||
|
|
|
||
|
|
# Create route builder for consistent URL structure
|
||
|
|
route_builder = RouteBuilder('inventory')
|
||
|
|
|
||
|
|
router = APIRouter(tags=["transformations"])
|
||
|
|
|
||
|
|
|
||
|
|
# 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:
|
||
|
|
# Handle service tokens that don't have UUID user_ids
|
||
|
|
if current_user.get('type') == 'service':
|
||
|
|
return None
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="User ID not found in context"
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
return UUID(user_id)
|
||
|
|
except (ValueError, TypeError):
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
route_builder.build_base_route("transformations"),
|
||
|
|
response_model=ProductTransformationResponse,
|
||
|
|
status_code=status.HTTP_201_CREATED
|
||
|
|
)
|
||
|
|
@require_user_role(['admin', 'owner', 'member'])
|
||
|
|
async def create_transformation(
|
||
|
|
transformation_data: ProductTransformationCreate,
|
||
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Create a new product transformation (e.g., par-baked to fully baked)"""
|
||
|
|
try:
|
||
|
|
# Extract user ID - handle service tokens
|
||
|
|
user_id = get_current_user_id(current_user)
|
||
|
|
|
||
|
|
service = TransformationService()
|
||
|
|
transformation = await service.create_transformation(transformation_data, tenant_id, user_id)
|
||
|
|
return transformation
|
||
|
|
except ValueError as e:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||
|
|
detail=str(e)
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to create transformation", error=str(e), tenant_id=tenant_id)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to create transformation"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_base_route("transformations"),
|
||
|
|
response_model=List[ProductTransformationResponse]
|
||
|
|
)
|
||
|
|
async def get_transformations(
|
||
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||
|
|
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"),
|
||
|
|
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient (source or target)"),
|
||
|
|
source_stage: Optional[ProductionStage] = Query(None, description="Filter by source production stage"),
|
||
|
|
target_stage: Optional[ProductionStage] = Query(None, description="Filter by target production stage"),
|
||
|
|
days_back: Optional[int] = Query(None, ge=1, le=365, description="Filter by days back from today"),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get product transformations with filtering"""
|
||
|
|
try:
|
||
|
|
service = TransformationService()
|
||
|
|
transformations = await service.get_transformations(
|
||
|
|
tenant_id, skip, limit, ingredient_id, source_stage, target_stage, days_back
|
||
|
|
)
|
||
|
|
return transformations
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get transformations", error=str(e), tenant_id=tenant_id)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to get transformations"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_resource_detail_route("transformations", "transformation_id"),
|
||
|
|
response_model=ProductTransformationResponse
|
||
|
|
)
|
||
|
|
async def get_transformation(
|
||
|
|
transformation_id: UUID = Path(..., description="Transformation ID"),
|
||
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get specific transformation by ID"""
|
||
|
|
try:
|
||
|
|
service = TransformationService()
|
||
|
|
transformation = await service.get_transformation(transformation_id, tenant_id)
|
||
|
|
|
||
|
|
if not transformation:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Transformation not found"
|
||
|
|
)
|
||
|
|
|
||
|
|
return transformation
|
||
|
|
except HTTPException:
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get transformation", error=str(e), transformation_id=transformation_id)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to get transformation"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_base_route("transformations/summary"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def get_transformation_summary(
|
||
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||
|
|
days_back: int = Query(30, ge=1, le=365, description="Days back for summary"),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get transformation summary for dashboard"""
|
||
|
|
try:
|
||
|
|
service = TransformationService()
|
||
|
|
summary = await service.get_transformation_summary(tenant_id, days_back)
|
||
|
|
return summary
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get transformation summary", error=str(e), tenant_id=tenant_id)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to get transformation summary"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
route_builder.build_operations_route("transformations/par-bake-to-fresh"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
@require_user_role(['admin', 'owner', 'member'])
|
||
|
|
async def create_par_bake_transformation(
|
||
|
|
source_ingredient_id: UUID = Query(..., description="Par-baked ingredient ID"),
|
||
|
|
target_ingredient_id: UUID = Query(..., description="Fresh baked ingredient ID"),
|
||
|
|
quantity: float = Query(..., gt=0, description="Quantity to transform"),
|
||
|
|
target_batch_number: Optional[str] = Query(None, description="Target batch number"),
|
||
|
|
expiration_hours: int = Query(24, ge=1, le=72, description="Hours until expiration after baking"),
|
||
|
|
notes: Optional[str] = Query(None, description="Process notes"),
|
||
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Convenience endpoint for par-baked to fresh transformation"""
|
||
|
|
try:
|
||
|
|
# Extract user ID - handle service tokens
|
||
|
|
user_id = get_current_user_id(current_user)
|
||
|
|
|
||
|
|
# Create transformation data for par-baked to fully baked
|
||
|
|
transformation_data = ProductTransformationCreate(
|
||
|
|
source_ingredient_id=str(source_ingredient_id),
|
||
|
|
target_ingredient_id=str(target_ingredient_id),
|
||
|
|
source_stage=ProductionStage.PAR_BAKED,
|
||
|
|
target_stage=ProductionStage.FULLY_BAKED,
|
||
|
|
source_quantity=quantity,
|
||
|
|
target_quantity=quantity, # Assume 1:1 ratio for par-baked goods
|
||
|
|
expiration_calculation_method="days_from_transformation",
|
||
|
|
expiration_days_offset=max(1, expiration_hours // 24), # Convert hours to days, minimum 1 day
|
||
|
|
process_notes=notes,
|
||
|
|
target_batch_number=target_batch_number
|
||
|
|
)
|
||
|
|
|
||
|
|
service = TransformationService()
|
||
|
|
transformation = await service.create_transformation(transformation_data, tenant_id, user_id)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"transformation_id": transformation.id,
|
||
|
|
"transformation_reference": transformation.transformation_reference,
|
||
|
|
"source_quantity": transformation.source_quantity,
|
||
|
|
"target_quantity": transformation.target_quantity,
|
||
|
|
"expiration_date": transformation.transformation_date,
|
||
|
|
"message": f"Successfully transformed {quantity} units from par-baked to fresh baked"
|
||
|
|
}
|
||
|
|
|
||
|
|
except ValueError as e:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||
|
|
detail=str(e)
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to create par-bake transformation", error=str(e), tenant_id=tenant_id)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to create par-bake transformation"
|
||
|
|
)
|