Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View File

View File

@@ -0,0 +1,231 @@
# services/inventory/app/api/classification.py
"""
Product Classification API Endpoints
AI-powered product classification for onboarding automation
"""
from fastapi import APIRouter, Depends, HTTPException, Path
from typing import List, Dict, Any
from uuid import UUID
from pydantic import BaseModel, Field
import structlog
from app.services.product_classifier import ProductClassifierService, get_product_classifier
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
router = APIRouter(tags=["classification"])
logger = structlog.get_logger()
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
estimated_shelf_life_days: int = None
requires_refrigeration: bool = False
requires_freezing: bool = False
is_seasonal: bool = False
suggested_supplier: str = None
notes: str = None
class BusinessModelAnalysisResponse(BaseModel):
"""Response with business model analysis"""
model: str # production, retail, hybrid
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
@router.post("/tenants/{tenant_id}/inventory/classify-product", response_model=ProductSuggestionResponse)
async def classify_single_product(
request: ProductClassificationRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
classifier: ProductClassifierService = Depends(get_product_classifier)
):
"""Classify a single product for inventory creation"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Classify the product
suggestion = classifier.classify_product(
request.product_name,
request.sales_volume
)
# Convert to response format
response = ProductSuggestionResponse(
suggestion_id=str(UUID.uuid4()), # Generate unique ID for tracking
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
)
logger.info("Classified single product",
product=request.product_name,
classification=suggestion.product_type.value,
confidence=suggestion.confidence_score,
tenant_id=tenant_id)
return response
except Exception as e:
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)}")
@router.post("/tenants/{tenant_id}/inventory/classify-products-batch", response_model=BatchClassificationResponse)
async def classify_products_batch(
request: BatchClassificationRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
classifier: ProductClassifierService = Depends(get_product_classifier)
):
"""Classify multiple products for onboarding automation"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
if not request.products:
raise HTTPException(status_code=400, detail="No products provided for classification")
# Extract product names and volumes
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}
# Classify products in batch
suggestions = classifier.classify_products_batch(product_names, sales_volumes)
# Convert suggestions to response format
suggestion_responses = []
for suggestion in suggestions:
suggestion_responses.append(ProductSuggestionResponse(
suggestion_id=str(UUID.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
))
# 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')
total = len(suggestions)
ingredient_ratio = ingredient_count / total if total > 0 else 0
# Determine business model
if ingredient_ratio >= 0.7:
model = 'production'
elif ingredient_ratio <= 0.3:
model = 'retail'
else:
model = 'hybrid'
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
recommendations = {
'production': [
'Focus on ingredient inventory management',
'Set up recipe cost calculation',
'Configure supplier relationships',
'Enable production planning features'
],
'retail': [
'Configure central baker relationships',
'Set up delivery schedule tracking',
'Enable finished product freshness monitoring',
'Focus on sales forecasting'
],
'hybrid': [
'Configure both ingredient and finished product management',
'Set up flexible inventory categories',
'Enable both production and retail features'
]
}
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, [])
)
# Count confidence levels
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)
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
)
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)
return response
except Exception as e:
logger.error("Failed batch classification",
error=str(e), products_count=len(request.products), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Batch classification failed: {str(e)}")

View File

@@ -0,0 +1,208 @@
# services/inventory/app/api/ingredients.py
"""
API endpoints for ingredient management
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.inventory_service import InventoryService
from app.schemas.inventory import (
IngredientCreate,
IngredientUpdate,
IngredientResponse,
InventoryFilter,
PaginatedResponse
)
from shared.auth.decorators import get_current_user_dep
from shared.auth.tenant_access import verify_tenant_access_dep
router = APIRouter(prefix="/ingredients", tags=["ingredients"])
# 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)
@router.post("/", response_model=IngredientResponse)
async def create_ingredient(
ingredient_data: IngredientCreate,
tenant_id: UUID = Depends(verify_tenant_access_dep),
user_id: UUID = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db)
):
"""Create a new ingredient"""
try:
service = InventoryService()
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
return ingredient
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 create ingredient"
)
@router.get("/{ingredient_id}", response_model=IngredientResponse)
async def get_ingredient(
ingredient_id: UUID,
tenant_id: UUID = Depends(verify_tenant_access_dep),
db: AsyncSession = Depends(get_db)
):
"""Get ingredient by ID"""
try:
service = InventoryService()
ingredient = await service.get_ingredient(ingredient_id, tenant_id)
if not ingredient:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ingredient not found"
)
return ingredient
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get ingredient"
)
@router.put("/{ingredient_id}", response_model=IngredientResponse)
async def update_ingredient(
ingredient_id: UUID,
ingredient_data: IngredientUpdate,
tenant_id: UUID = Depends(verify_tenant_access_dep),
db: AsyncSession = Depends(get_db)
):
"""Update ingredient"""
try:
service = InventoryService()
ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id)
if not ingredient:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ingredient not found"
)
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"
)
@router.get("/", response_model=List[IngredientResponse])
async def list_ingredients(
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"),
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"),
tenant_id: UUID = Depends(verify_tenant_access_dep),
db: AsyncSession = Depends(get_db)
):
"""List ingredients with filtering"""
try:
service = InventoryService()
# Build filters
filters = {}
if category:
filters['category'] = category
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
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"
)
@router.delete("/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ingredient(
ingredient_id: UUID,
tenant_id: UUID = Depends(verify_tenant_access_dep),
db: AsyncSession = Depends(get_db)
):
"""Soft delete ingredient (mark as inactive)"""
try:
service = InventoryService()
ingredient = await service.update_ingredient(
ingredient_id,
{"is_active": False},
tenant_id
)
if not ingredient:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ingredient not found"
)
return None
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete ingredient"
)
@router.get("/{ingredient_id}/stock", response_model=List[dict])
async def get_ingredient_stock(
ingredient_id: UUID,
include_unavailable: bool = Query(False, description="Include unavailable stock"),
tenant_id: UUID = Depends(verify_tenant_access_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock entries for an ingredient"""
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"
)

View File

@@ -0,0 +1,167 @@
# services/inventory/app/api/stock.py
"""
API endpoints for stock management
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.inventory_service import InventoryService
from app.schemas.inventory import (
StockCreate,
StockUpdate,
StockResponse,
StockMovementCreate,
StockMovementResponse,
StockFilter
)
from shared.auth.decorators import get_current_user_dep
from shared.auth.tenant_access import verify_tenant_access_dep
router = APIRouter(prefix="/stock", tags=["stock"])
# 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)
@router.post("/", response_model=StockResponse)
async def add_stock(
stock_data: StockCreate,
tenant_id: UUID = Depends(verify_tenant_access_dep),
user_id: UUID = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db)
):
"""Add new stock entry"""
try:
service = InventoryService()
stock = await service.add_stock(stock_data, tenant_id, user_id)
return stock
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 add stock"
)
@router.post("/consume")
async def consume_stock(
ingredient_id: UUID,
quantity: float = Query(..., gt=0, description="Quantity to consume"),
reference_number: Optional[str] = Query(None, description="Reference number (e.g., production order)"),
notes: Optional[str] = Query(None, description="Additional notes"),
fifo: bool = Query(True, description="Use FIFO (First In, First Out) method"),
tenant_id: UUID = Depends(verify_tenant_access_dep),
user_id: UUID = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db)
):
"""Consume stock for production"""
try:
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("/ingredient/{ingredient_id}", response_model=List[StockResponse])
async def get_ingredient_stock(
ingredient_id: UUID,
include_unavailable: bool = Query(False, description="Include unavailable stock"),
tenant_id: UUID = Depends(verify_tenant_access_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock entries for an ingredient"""
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"
)
@router.get("/expiring", response_model=List[dict])
async def get_expiring_stock(
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check for expiring items"),
tenant_id: UUID = Depends(verify_tenant_access_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("/low-stock", response_model=List[dict])
async def get_low_stock(
tenant_id: UUID = Depends(verify_tenant_access_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("/summary", response_model=dict)
async def get_stock_summary(
tenant_id: UUID = Depends(verify_tenant_access_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock summary for dashboard"""
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"
)