Fix new services implementation 2

This commit is contained in:
Urtzi Alfaro
2025-08-14 13:26:59 +02:00
parent 262b3dc9c4
commit 0951547e92
39 changed files with 1203 additions and 917 deletions

View File

@@ -5,8 +5,8 @@ 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 typing import List, Dict, Any, Optional
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
import structlog
@@ -38,12 +38,12 @@ class ProductSuggestionResponse(BaseModel):
category: str
unit_of_measure: str
confidence_score: float
estimated_shelf_life_days: int = None
estimated_shelf_life_days: Optional[int] = None
requires_refrigeration: bool = False
requires_freezing: bool = False
is_seasonal: bool = False
suggested_supplier: str = None
notes: str = None
suggested_supplier: Optional[str] = None
notes: Optional[str] = None
class BusinessModelAnalysisResponse(BaseModel):
@@ -87,7 +87,7 @@ async def classify_single_product(
# Convert to response format
response = ProductSuggestionResponse(
suggestion_id=str(UUID.uuid4()), # Generate unique ID for tracking
suggestion_id=str(uuid4()), # Generate unique ID for tracking
original_name=suggestion.original_name,
suggested_name=suggestion.suggested_name,
product_type=suggestion.product_type.value,
@@ -144,7 +144,7 @@ async def classify_products_batch(
suggestion_responses = []
for suggestion in suggestions:
suggestion_responses.append(ProductSuggestionResponse(
suggestion_id=str(UUID.uuid4()),
suggestion_id=str(uuid4()),
original_name=suggestion.original_name,
suggested_name=suggestion.suggested_name,
product_type=suggestion.product_type.value,
@@ -159,39 +159,58 @@ async def classify_products_batch(
notes=suggestion.notes
))
# Analyze business model
# Analyze business model with enhanced detection
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')
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
semi_finished_ratio = semi_finished_count / total if total > 0 else 0
# Determine business model
# Enhanced business model determination
if ingredient_ratio >= 0.7:
model = 'production'
model = 'individual_bakery' # Full production from raw ingredients
elif ingredient_ratio <= 0.2 and semi_finished_ratio >= 0.3:
model = 'central_baker_satellite' # Receives semi-finished products from central baker
elif ingredient_ratio <= 0.3:
model = 'retail'
model = 'retail_bakery' # Sells finished products from suppliers
else:
model = 'hybrid'
model = 'hybrid_bakery' # Mixed model
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
# Calculate confidence based on clear distinction
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)
recommendations = {
'production': [
'Focus on ingredient inventory management',
'Set up recipe cost calculation',
'Configure supplier relationships',
'Enable production planning features'
'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'
],
'retail': [
'Configure central baker relationships',
'Set up delivery schedule tracking',
'Enable finished product freshness monitoring',
'Focus on sales forecasting'
'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'
],
'hybrid': [
'Configure both ingredient and finished product management',
'Set up flexible inventory categories',
'Enable both production and retail features'
'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'
],
'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'
]
}

View File

@@ -5,7 +5,7 @@ API endpoints for ingredient management
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
@@ -17,10 +17,9 @@ from app.schemas.inventory import (
InventoryFilter,
PaginatedResponse
)
from shared.auth.decorators import get_current_user_dep
from shared.auth.tenant_access import verify_tenant_access_dep
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
router = APIRouter(prefix="/ingredients", tags=["ingredients"])
router = APIRouter(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:
@@ -34,15 +33,31 @@ def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> U
return UUID(user_id)
@router.post("/", response_model=IngredientResponse)
@router.post("/tenants/{tenant_id}/ingredients", 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),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create a new ingredient"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Extract user ID - handle service tokens that don't have UUID user_ids
raw_user_id = current_user.get('user_id')
if current_user.get('type') == 'service':
# For service tokens, user_id might not be a UUID, so set to None
user_id = None
else:
try:
user_id = UUID(raw_user_id)
except (ValueError, TypeError):
user_id = None
service = InventoryService()
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
return ingredient
@@ -58,14 +73,20 @@ async def create_ingredient(
)
@router.get("/{ingredient_id}", response_model=IngredientResponse)
@router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
async def get_ingredient(
ingredient_id: UUID,
tenant_id: UUID = Depends(verify_tenant_access_dep),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get ingredient by ID"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService()
ingredient = await service.get_ingredient(ingredient_id, tenant_id)
@@ -85,15 +106,21 @@ async def get_ingredient(
)
@router.put("/{ingredient_id}", response_model=IngredientResponse)
@router.put("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
async def update_ingredient(
ingredient_id: UUID,
ingredient_data: IngredientUpdate,
tenant_id: UUID = Depends(verify_tenant_access_dep),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Update ingredient"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService()
ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id)
@@ -118,8 +145,9 @@ async def update_ingredient(
)
@router.get("/", response_model=List[IngredientResponse])
@router.get("/tenants/{tenant_id}/ingredients", response_model=List[IngredientResponse])
async def list_ingredients(
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"),
category: Optional[str] = Query(None, description="Filter by category"),
@@ -127,11 +155,16 @@ async def list_ingredients(
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),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""List ingredients with filtering"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService()
# Build filters
@@ -156,14 +189,20 @@ async def list_ingredients(
)
@router.delete("/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/tenants/{tenant_id}/ingredients/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ingredient(
ingredient_id: UUID,
tenant_id: UUID = Depends(verify_tenant_access_dep),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Soft delete ingredient (mark as inactive)"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService()
ingredient = await service.update_ingredient(
ingredient_id,
@@ -187,15 +226,21 @@ async def delete_ingredient(
)
@router.get("/{ingredient_id}/stock", response_model=List[dict])
@router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}/stock", response_model=List[dict])
async def get_ingredient_stock(
ingredient_id: UUID,
tenant_id: UUID = Path(..., description="Tenant ID"),
include_unavailable: bool = Query(False, description="Include unavailable stock"),
tenant_id: UUID = Depends(verify_tenant_access_dep),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock entries for an ingredient"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService()
stock_entries = await service.get_stock_by_ingredient(
ingredient_id, tenant_id, include_unavailable

View File

@@ -160,6 +160,14 @@ class Ingredient(Base):
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
# Map to response schema format - use ingredient_category as primary category
category = None
if self.ingredient_category:
category = self.ingredient_category.value
elif self.product_category:
# For finished products, we could map to a generic category
category = "other"
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
@@ -167,6 +175,7 @@ class Ingredient(Base):
'sku': self.sku,
'barcode': self.barcode,
'product_type': self.product_type.value if self.product_type else None,
'category': category, # Map to what IngredientResponse expects
'ingredient_category': self.ingredient_category.value if self.ingredient_category else None,
'product_category': self.product_category.value if self.product_category else None,
'subcategory': self.subcategory,

View File

@@ -26,17 +26,53 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
async def create_ingredient(self, ingredient_data: IngredientCreate, tenant_id: UUID) -> Ingredient:
"""Create a new ingredient"""
try:
# Prepare data
# Prepare data and map schema fields to model fields
create_data = ingredient_data.model_dump()
create_data['tenant_id'] = tenant_id
# Map 'category' from schema to appropriate model fields
if 'category' in create_data:
category_value = create_data.pop('category')
# For now, assume all items are ingredients and map to ingredient_category
# Convert string to enum object
from app.models.inventory import IngredientCategory
try:
# Find the enum member by value
for enum_member in IngredientCategory:
if enum_member.value == category_value:
create_data['ingredient_category'] = enum_member
break
else:
# If not found, default to OTHER
create_data['ingredient_category'] = IngredientCategory.OTHER
except Exception:
# Fallback to OTHER if any issues
create_data['ingredient_category'] = IngredientCategory.OTHER
# Convert unit_of_measure string to enum object
if 'unit_of_measure' in create_data:
unit_value = create_data['unit_of_measure']
from app.models.inventory import UnitOfMeasure
try:
# Find the enum member by value
for enum_member in UnitOfMeasure:
if enum_member.value == unit_value:
create_data['unit_of_measure'] = enum_member
break
else:
# If not found, default to UNITS
create_data['unit_of_measure'] = UnitOfMeasure.UNITS
except Exception:
# Fallback to UNITS if any issues
create_data['unit_of_measure'] = UnitOfMeasure.UNITS
# Create record
record = await self.create(create_data)
logger.info(
"Created ingredient",
ingredient_id=record.id,
name=record.name,
category=record.category.value if record.category else None,
ingredient_category=record.ingredient_category.value if record.ingredient_category else None,
tenant_id=tenant_id
)
return record