Files
bakery-ia/services/inventory/app/api/inventory_operations.py

368 lines
14 KiB
Python
Raw Normal View History

2025-10-06 15:27:01 +02:00
# services/inventory/app/api/inventory_operations.py
"""
2025-10-06 15:27:01 +02:00
Inventory Operations API - Business operations for inventory management
"""
2025-10-06 15:27:01 +02:00
from typing import List, Optional, Dict, Any
2025-08-14 13:26:59 +02:00
from uuid import UUID, uuid4
2025-10-06 15:27:01 +02:00
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
import structlog
2025-10-06 15:27:01 +02:00
from app.core.database import get_db
from app.services.inventory_service import InventoryService
from app.services.product_classifier import ProductClassifierService, get_product_classifier
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
from shared.routing import RouteBuilder
logger = structlog.get_logger()
2025-10-06 15:27:01 +02:00
route_builder = RouteBuilder('inventory')
router = APIRouter(tags=["inventory-operations"])
2025-10-06 15:27:01 +02:00
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:
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
# ===== Stock Operations =====
@router.post(
route_builder.build_operations_route("consume-stock"),
response_model=dict
)
@require_user_role(['admin', 'owner', 'member'])
async def consume_stock(
tenant_id: UUID = Path(..., description="Tenant ID"),
ingredient_id: UUID = Query(..., description="Ingredient ID to consume"),
quantity: float = Query(..., gt=0, description="Quantity to consume"),
reference_number: Optional[str] = Query(None, description="Reference number"),
notes: Optional[str] = Query(None, description="Additional notes"),
fifo: bool = Query(True, description="Use FIFO method"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Consume stock for production"""
try:
user_id = get_current_user_id(current_user)
service = InventoryService()
consumed_items = await service.consume_stock(
ingredient_id, quantity, tenant_id, user_id, reference_number, notes, fifo
)
return {
"ingredient_id": str(ingredient_id),
"total_quantity_consumed": quantity,
"consumed_items": consumed_items,
"method": "FIFO" if fifo else "LIFO"
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to consume stock"
)
@router.get(
route_builder.build_operations_route("stock/expiring"),
response_model=List[dict]
)
async def get_expiring_stock(
tenant_id: UUID = Path(..., description="Tenant ID"),
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock items expiring within specified days"""
try:
service = InventoryService()
expiring_items = await service.check_expiration_alerts(tenant_id, days_ahead)
return expiring_items
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get expiring stock"
)
@router.get(
route_builder.build_operations_route("stock/low-stock"),
response_model=List[dict]
)
async def get_low_stock(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get ingredients with low stock levels"""
try:
service = InventoryService()
low_stock_items = await service.check_low_stock_alerts(tenant_id)
return low_stock_items
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get low stock items"
)
@router.get(
route_builder.build_operations_route("stock/summary"),
response_model=dict
)
async def get_stock_summary(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock summary for tenant"""
try:
service = InventoryService()
summary = await service.get_inventory_summary(tenant_id)
return summary.dict()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get stock summary"
)
# ===== Product Classification Operations =====
class ProductClassificationRequest(BaseModel):
"""Request for single product classification"""
product_name: str = Field(..., description="Product name to classify")
sales_volume: float = Field(None, description="Total sales volume for context")
sales_data: Dict[str, Any] = Field(default_factory=dict, description="Additional sales context")
class BatchClassificationRequest(BaseModel):
"""Request for batch product classification"""
products: List[ProductClassificationRequest] = Field(..., description="Products to classify")
class ProductSuggestionResponse(BaseModel):
"""Response with product classification suggestion"""
suggestion_id: str
original_name: str
suggested_name: str
product_type: str
category: str
unit_of_measure: str
confidence_score: float
2025-08-14 13:26:59 +02:00
estimated_shelf_life_days: Optional[int] = None
requires_refrigeration: bool = False
requires_freezing: bool = False
is_seasonal: bool = False
2025-08-14 13:26:59 +02:00
suggested_supplier: Optional[str] = None
notes: Optional[str] = None
class BusinessModelAnalysisResponse(BaseModel):
"""Response with business model analysis"""
2025-10-06 15:27:01 +02:00
model: str
confidence: float
ingredient_count: int
finished_product_count: int
ingredient_ratio: float
recommendations: List[str]
class BatchClassificationResponse(BaseModel):
"""Response for batch classification"""
suggestions: List[ProductSuggestionResponse]
business_model_analysis: BusinessModelAnalysisResponse
total_products: int
high_confidence_count: int
low_confidence_count: int
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("classify-product"),
response_model=ProductSuggestionResponse
)
async def classify_single_product(
request: ProductClassificationRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
classifier: ProductClassifierService = Depends(get_product_classifier)
):
"""Classify a single product for inventory creation"""
try:
suggestion = classifier.classify_product(
2025-10-06 15:27:01 +02:00
request.product_name,
request.sales_volume
)
2025-10-06 15:27:01 +02:00
response = ProductSuggestionResponse(
2025-10-06 15:27:01 +02:00
suggestion_id=str(uuid4()),
original_name=suggestion.original_name,
suggested_name=suggestion.suggested_name,
product_type=suggestion.product_type.value,
category=suggestion.category,
unit_of_measure=suggestion.unit_of_measure.value,
confidence_score=suggestion.confidence_score,
estimated_shelf_life_days=suggestion.estimated_shelf_life_days,
requires_refrigeration=suggestion.requires_refrigeration,
requires_freezing=suggestion.requires_freezing,
is_seasonal=suggestion.is_seasonal,
suggested_supplier=suggestion.suggested_supplier,
notes=suggestion.notes
)
2025-10-06 15:27:01 +02:00
logger.info("Classified single product",
product=request.product_name,
classification=suggestion.product_type.value,
confidence=suggestion.confidence_score,
tenant_id=tenant_id)
2025-10-06 15:27:01 +02:00
return response
2025-10-06 15:27:01 +02:00
except Exception as e:
2025-10-06 15:27:01 +02:00
logger.error("Failed to classify product",
error=str(e), product=request.product_name, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Classification failed: {str(e)}")
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("classify-products-batch"),
response_model=BatchClassificationResponse
)
async def classify_products_batch(
request: BatchClassificationRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
classifier: ProductClassifierService = Depends(get_product_classifier)
):
"""Classify multiple products for onboarding automation"""
try:
if not request.products:
raise HTTPException(status_code=400, detail="No products provided for classification")
2025-10-06 15:27:01 +02:00
product_names = [p.product_name for p in request.products]
sales_volumes = {p.product_name: p.sales_volume for p in request.products if p.sales_volume}
2025-10-06 15:27:01 +02:00
suggestions = classifier.classify_products_batch(product_names, sales_volumes)
2025-10-06 15:27:01 +02:00
suggestion_responses = []
for suggestion in suggestions:
suggestion_responses.append(ProductSuggestionResponse(
2025-08-14 13:26:59 +02:00
suggestion_id=str(uuid4()),
original_name=suggestion.original_name,
suggested_name=suggestion.suggested_name,
product_type=suggestion.product_type.value,
category=suggestion.category,
unit_of_measure=suggestion.unit_of_measure.value,
confidence_score=suggestion.confidence_score,
estimated_shelf_life_days=suggestion.estimated_shelf_life_days,
requires_refrigeration=suggestion.requires_refrigeration,
requires_freezing=suggestion.requires_freezing,
is_seasonal=suggestion.is_seasonal,
suggested_supplier=suggestion.suggested_supplier,
notes=suggestion.notes
))
2025-10-06 15:27:01 +02:00
# Analyze business model
ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'ingredient')
finished_count = sum(1 for s in suggestions if s.product_type.value == 'finished_product')
2025-08-14 13:26:59 +02:00
semi_finished_count = sum(1 for s in suggestions if 'semi' in s.suggested_name.lower() or 'frozen' in s.suggested_name.lower() or 'pre' in s.suggested_name.lower())
total = len(suggestions)
ingredient_ratio = ingredient_count / total if total > 0 else 0
2025-08-14 13:26:59 +02:00
semi_finished_ratio = semi_finished_count / total if total > 0 else 0
2025-10-06 15:27:01 +02:00
if ingredient_ratio >= 0.7:
2025-10-06 15:27:01 +02:00
model = 'individual_bakery'
2025-08-14 13:26:59 +02:00
elif ingredient_ratio <= 0.2 and semi_finished_ratio >= 0.3:
2025-10-06 15:27:01 +02:00
model = 'central_baker_satellite'
elif ingredient_ratio <= 0.3:
2025-10-06 15:27:01 +02:00
model = 'retail_bakery'
else:
2025-10-06 15:27:01 +02:00
model = 'hybrid_bakery'
2025-08-14 13:26:59 +02:00
if model == 'individual_bakery':
confidence = min(ingredient_ratio * 1.2, 0.95)
elif model == 'central_baker_satellite':
confidence = min((semi_finished_ratio + (1 - ingredient_ratio)) / 2 * 1.2, 0.95)
else:
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
2025-10-06 15:27:01 +02:00
recommendations = {
2025-08-14 13:26:59 +02:00
'individual_bakery': [
'Set up raw ingredient inventory management',
'Configure recipe cost calculation and production planning',
'Enable supplier relationships for flour, yeast, sugar, etc.',
'Set up full production workflow with proofing and baking schedules',
'Enable waste tracking for overproduction'
],
'central_baker_satellite': [
'Configure central baker delivery schedules',
'Set up semi-finished product inventory (frozen dough, par-baked items)',
'Enable finish-baking workflow and timing optimization',
'Track freshness and shelf-life for received products',
'Focus on customer demand forecasting for final products'
],
2025-08-14 13:26:59 +02:00
'retail_bakery': [
'Set up finished product supplier relationships',
'Configure delivery schedule tracking',
'Enable freshness monitoring and expiration management',
'Focus on sales forecasting and customer preferences'
],
2025-08-14 13:26:59 +02:00
'hybrid_bakery': [
'Configure both ingredient and semi-finished product management',
'Set up flexible production workflows',
'Enable both supplier and central baker relationships',
'Configure multi-tier inventory categories'
]
}
2025-10-06 15:27:01 +02:00
business_model_analysis = BusinessModelAnalysisResponse(
model=model,
confidence=confidence,
ingredient_count=ingredient_count,
finished_product_count=finished_count,
ingredient_ratio=ingredient_ratio,
recommendations=recommendations.get(model, [])
)
2025-10-06 15:27:01 +02:00
high_confidence_count = sum(1 for s in suggestions if s.confidence_score >= 0.7)
low_confidence_count = sum(1 for s in suggestions if s.confidence_score < 0.6)
2025-10-06 15:27:01 +02:00
response = BatchClassificationResponse(
suggestions=suggestion_responses,
business_model_analysis=business_model_analysis,
total_products=len(suggestions),
high_confidence_count=high_confidence_count,
low_confidence_count=low_confidence_count
)
2025-10-06 15:27:01 +02:00
logger.info("Batch classification complete",
total_products=len(suggestions),
business_model=model,
high_confidence=high_confidence_count,
low_confidence=low_confidence_count,
tenant_id=tenant_id)
2025-10-06 15:27:01 +02:00
return response
2025-10-06 15:27:01 +02:00
except Exception as e:
2025-10-06 15:27:01 +02:00
logger.error("Failed batch classification",
error=str(e), products_count=len(request.products), tenant_id=tenant_id)
2025-10-06 15:27:01 +02:00
raise HTTPException(status_code=500, detail=f"Batch classification failed: {str(e)}")