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

View File

@@ -25,6 +25,27 @@ logger = structlog.get_logger()
class FileValidationResponse(BaseModel):
"""Response for file validation step"""
is_valid: bool
total_records: int
unique_products: int
product_list: List[str]
validation_errors: List[Any]
validation_warnings: List[Any]
summary: Dict[str, Any]
class ProductSuggestionsResponse(BaseModel):
"""Response for AI suggestions step"""
suggestions: List[Dict[str, Any]]
business_model_analysis: Dict[str, Any]
total_products: int
high_confidence_count: int
low_confidence_count: int
processing_time_seconds: float
class InventoryApprovalRequest(BaseModel):
"""Request to approve/modify inventory suggestions"""
suggestions: List[Dict[str, Any]] = Field(..., description="Approved suggestions with modifications")

View File

@@ -310,6 +310,19 @@ class AIOnboardingService:
processing_time_seconds=processing_time
)
# Update tenant's business model based on AI analysis
if business_model.model != "unknown" and business_model.confidence >= 0.6:
try:
await self._update_tenant_business_model(tenant_id, business_model.model)
logger.info("Updated tenant business model",
tenant_id=tenant_id,
business_model=business_model.model,
confidence=business_model.confidence)
except Exception as e:
logger.warning("Failed to update tenant business model",
error=str(e), tenant_id=tenant_id)
# Don't fail the entire process if tenant update fails
logger.info("AI inventory suggestions completed",
total_suggestions=len(suggestions),
business_model=business_model.model,
@@ -385,21 +398,68 @@ class AIOnboardingService:
try:
# Build inventory item data from suggestion and modifications
# Map to inventory service expected format
raw_category = modifications.get("category") or approval.get("category", "other")
raw_unit = modifications.get("unit_of_measure") or approval.get("unit_of_measure", "units")
# Map categories to inventory service enum values
category_mapping = {
"flour": "flour",
"yeast": "yeast",
"dairy": "dairy",
"eggs": "eggs",
"sugar": "sugar",
"fats": "fats",
"salt": "salt",
"spices": "spices",
"additives": "additives",
"packaging": "packaging",
"cleaning": "cleaning",
"grains": "flour", # Map common variations
"bread": "other",
"pastries": "other",
"croissants": "other",
"cakes": "other",
"other_products": "other"
}
# Map units to inventory service enum values
unit_mapping = {
"kg": "kg",
"kilograms": "kg",
"g": "g",
"grams": "g",
"l": "l",
"liters": "l",
"ml": "ml",
"milliliters": "ml",
"units": "units",
"pieces": "pcs",
"pcs": "pcs",
"packages": "pkg",
"pkg": "pkg",
"bags": "bags",
"boxes": "boxes"
}
mapped_category = category_mapping.get(raw_category.lower(), "other")
mapped_unit = unit_mapping.get(raw_unit.lower(), "units")
inventory_data = {
"name": modifications.get("name") or approval.get("suggested_name"),
"product_type": modifications.get("product_type") or approval.get("product_type"),
"category": modifications.get("category") or approval.get("category"),
"unit_of_measure": modifications.get("unit_of_measure") or approval.get("unit_of_measure"),
"category": mapped_category,
"unit_of_measure": mapped_unit,
"description": modifications.get("description") or approval.get("notes", ""),
"estimated_shelf_life_days": modifications.get("estimated_shelf_life_days") or approval.get("estimated_shelf_life_days"),
"requires_refrigeration": modifications.get("requires_refrigeration", approval.get("requires_refrigeration", False)),
"requires_freezing": modifications.get("requires_freezing", approval.get("requires_freezing", False)),
"is_seasonal": modifications.get("is_seasonal", approval.get("is_seasonal", False)),
"suggested_supplier": modifications.get("suggested_supplier") or approval.get("suggested_supplier"),
"is_active": True,
"source": "ai_onboarding"
# Optional fields
"brand": modifications.get("brand") or approval.get("suggested_supplier"),
"is_active": True
}
# Add optional numeric fields only if they exist
shelf_life = modifications.get("estimated_shelf_life_days") or approval.get("estimated_shelf_life_days")
if shelf_life:
inventory_data["shelf_life_days"] = shelf_life
# Create inventory item via inventory service
created_item = await self.inventory_client.create_ingredient(
inventory_data, str(tenant_id)
@@ -619,6 +679,47 @@ class AIOnboardingService:
except Exception as e:
logger.warning("Failed to analyze product sales data", error=str(e))
return {}
async def _update_tenant_business_model(self, tenant_id: UUID, business_model: str) -> None:
"""Update tenant's business model based on AI analysis"""
try:
# Use the gateway URL for all inter-service communication
from app.core.config import settings
import httpx
gateway_url = settings.GATEWAY_URL
url = f"{gateway_url}/api/v1/tenants/{tenant_id}"
# Prepare update data
update_data = {
"business_model": business_model
}
# Make request through gateway
timeout_config = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.put(
url,
json=update_data,
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
logger.info("Successfully updated tenant business model via gateway",
tenant_id=tenant_id, business_model=business_model)
else:
logger.warning("Failed to update tenant business model via gateway",
tenant_id=tenant_id,
status_code=response.status_code,
response=response.text)
except Exception as e:
logger.error("Error updating tenant business model via gateway",
tenant_id=tenant_id,
business_model=business_model,
error=str(e))
raise
# Factory function for dependency injection

View File

@@ -11,13 +11,16 @@ import base64
import pandas as pd
from typing import Dict, Any, List, Optional, Union
from datetime import datetime, timezone
from uuid import UUID
import structlog
import re
import asyncio
from app.repositories.sales_repository import SalesRepository
from app.models.sales import SalesData
from app.schemas.sales import SalesDataCreate
from app.core.database import get_db_transaction
from app.services.inventory_client import InventoryServiceClient
logger = structlog.get_logger()
@@ -79,7 +82,10 @@ class DataImportService:
def __init__(self):
"""Initialize enhanced import service"""
pass
self.inventory_client = InventoryServiceClient()
# Product resolution cache for the import session
self.product_cache = {} # product_name -> inventory_product_id
self.failed_products = set() # Track products that failed to resolve
async def validate_import_data(self, data: Dict[str, Any]) -> SalesValidationResult:
"""Enhanced validation with better error handling and suggestions"""
@@ -349,6 +355,9 @@ class DataImportService:
start_time = datetime.utcnow()
try:
# Clear cache for new import session
self._clear_import_cache()
logger.info("Starting enhanced data import",
filename=filename,
format=file_format,
@@ -451,12 +460,24 @@ class DataImportService:
warnings.extend(parsed_data.get("warnings", []))
continue
# Resolve product name to inventory_product_id
inventory_product_id = await self._resolve_product_to_inventory_id(
parsed_data["product_name"],
parsed_data.get("product_category"),
tenant_id
)
if not inventory_product_id:
error_msg = f"Row {index + 1}: Could not resolve product '{parsed_data['product_name']}' to inventory ID"
errors.append(error_msg)
logger.warning("Product resolution failed", error=error_msg)
continue
# Create sales record with enhanced data
sales_data = SalesDataCreate(
tenant_id=tenant_id,
date=parsed_data["date"],
product_name=parsed_data["product_name"],
product_category=parsed_data.get("product_category"),
inventory_product_id=inventory_product_id,
quantity_sold=parsed_data["quantity_sold"],
unit_price=parsed_data.get("unit_price"),
revenue=parsed_data.get("revenue"),
@@ -619,12 +640,24 @@ class DataImportService:
warnings.extend(parsed_data.get("warnings", []))
continue
# Resolve product name to inventory_product_id
inventory_product_id = await self._resolve_product_to_inventory_id(
parsed_data["product_name"],
parsed_data.get("product_category"),
tenant_id
)
if not inventory_product_id:
error_msg = f"Row {index + 1}: Could not resolve product '{parsed_data['product_name']}' to inventory ID"
errors.append(error_msg)
logger.warning("Product resolution failed", error=error_msg)
continue
# Create enhanced sales record
sales_data = SalesDataCreate(
tenant_id=tenant_id,
date=parsed_data["date"],
product_name=parsed_data["product_name"],
product_category=parsed_data.get("product_category"),
inventory_product_id=inventory_product_id,
quantity_sold=parsed_data["quantity_sold"],
unit_price=parsed_data.get("unit_price"),
revenue=parsed_data.get("revenue"),
@@ -874,6 +907,94 @@ class DataImportService:
return cleaned if cleaned else "Producto sin nombre"
def _clear_import_cache(self):
"""Clear the product resolution cache for a new import session"""
self.product_cache.clear()
self.failed_products.clear()
logger.info("Import cache cleared for new session")
async def _resolve_product_to_inventory_id(self, product_name: str, product_category: Optional[str], tenant_id: UUID) -> Optional[UUID]:
"""Resolve a product name to an inventory_product_id via the inventory service with caching and rate limiting"""
# Check cache first
if product_name in self.product_cache:
logger.debug("Product resolved from cache", product_name=product_name, tenant_id=tenant_id)
return self.product_cache[product_name]
# Skip if this product already failed to resolve
if product_name in self.failed_products:
logger.debug("Skipping previously failed product", product_name=product_name, tenant_id=tenant_id)
return None
max_retries = 3
base_delay = 1.0 # Start with 1 second delay
for attempt in range(max_retries):
try:
# Add delay before API calls to avoid rate limiting
if attempt > 0:
delay = base_delay * (2 ** (attempt - 1)) # Exponential backoff
logger.info(f"Retrying product resolution after {delay}s delay",
product_name=product_name, attempt=attempt, tenant_id=tenant_id)
await asyncio.sleep(delay)
# First try to search for existing product by name
products = await self.inventory_client.search_products(product_name, tenant_id)
if products:
# Return the first matching product's ID
product_id = products[0].get('id')
if product_id:
uuid_id = UUID(str(product_id))
self.product_cache[product_name] = uuid_id # Cache for future use
logger.info("Resolved product to existing inventory ID",
product_name=product_name, product_id=product_id, tenant_id=tenant_id)
return uuid_id
# Add small delay before creation attempt to avoid hitting rate limits
await asyncio.sleep(0.5)
# If not found, create a new ingredient/product in inventory
ingredient_data = {
'name': product_name,
'type': 'finished_product', # Assuming sales are of finished products
'unit': 'unit', # Default unit
'current_stock': 0, # No stock initially
'reorder_point': 0,
'cost_per_unit': 0,
'category': product_category or 'general'
}
created_product = await self.inventory_client.create_ingredient(ingredient_data, str(tenant_id))
if created_product and created_product.get('id'):
product_id = created_product['id']
uuid_id = UUID(str(product_id))
self.product_cache[product_name] = uuid_id # Cache for future use
logger.info("Created new inventory product for sales data",
product_name=product_name, product_id=product_id, tenant_id=tenant_id)
return uuid_id
logger.warning("Failed to resolve or create product in inventory",
product_name=product_name, tenant_id=tenant_id, attempt=attempt)
except Exception as e:
error_str = str(e)
if "429" in error_str or "rate limit" in error_str.lower():
logger.warning("Rate limit hit, retrying",
product_name=product_name, attempt=attempt, error=error_str, tenant_id=tenant_id)
if attempt < max_retries - 1:
continue # Retry with exponential backoff
else:
logger.error("Error resolving product to inventory ID",
error=error_str, product_name=product_name, tenant_id=tenant_id)
break # Don't retry for non-rate-limit errors
# If all retries failed, mark as failed and return None
self.failed_products.add(product_name)
logger.error("Failed to resolve product after all retries",
product_name=product_name, tenant_id=tenant_id)
return None
def _structure_messages(self, messages: List[Union[str, Dict]]) -> List[Dict[str, Any]]:
"""Convert string messages to structured format"""
structured = []

View File

@@ -117,6 +117,20 @@ class InventoryServiceClient:
logger.error("Error fetching products by category",
error=str(e), category=category, tenant_id=tenant_id)
return []
async def create_ingredient(self, ingredient_data: Dict[str, Any], tenant_id: str) -> Optional[Dict[str, Any]]:
"""Create a new ingredient/product in inventory service"""
try:
result = await self._shared_client.create_ingredient(ingredient_data, tenant_id)
if result:
logger.info("Created ingredient in inventory service",
ingredient_name=ingredient_data.get('name'), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error creating ingredient",
error=str(e), ingredient_data=ingredient_data, tenant_id=tenant_id)
return None
# Dependency injection
async def get_inventory_client() -> InventoryServiceClient:

View File

@@ -20,6 +20,7 @@ class Tenant(Base):
name = Column(String(200), nullable=False)
subdomain = Column(String(100), unique=True)
business_type = Column(String(100), default="bakery")
business_model = Column(String(100), default="individual_bakery") # individual_bakery, central_baker_satellite, retail_bakery, hybrid_bakery
# Location info
address = Column(Text, nullable=False)

View File

@@ -17,6 +17,7 @@ class BakeryRegistration(BaseModel):
postal_code: str = Field(..., pattern=r"^\d{5}$")
phone: str = Field(..., min_length=9, max_length=20)
business_type: str = Field(default="bakery")
business_model: Optional[str] = Field(default="individual_bakery")
@validator('phone')
def validate_spanish_phone(cls, v):
@@ -41,6 +42,15 @@ class BakeryRegistration(BaseModel):
if v not in valid_types:
raise ValueError(f'Business type must be one of: {valid_types}')
return v
@validator('business_model')
def validate_business_model(cls, v):
if v is None:
return v
valid_models = ['individual_bakery', 'central_baker_satellite', 'retail_bakery', 'hybrid_bakery']
if v not in valid_models:
raise ValueError(f'Business model must be one of: {valid_models}')
return v
class TenantResponse(BaseModel):
"""Tenant response schema - FIXED VERSION"""
@@ -48,6 +58,7 @@ class TenantResponse(BaseModel):
name: str
subdomain: Optional[str]
business_type: str
business_model: Optional[str]
address: str
city: str
postal_code: str
@@ -101,6 +112,7 @@ class TenantUpdate(BaseModel):
address: Optional[str] = Field(None, min_length=10, max_length=500)
phone: Optional[str] = None
business_type: Optional[str] = None
business_model: Optional[str] = None
class TenantListResponse(BaseModel):
"""Response schema for listing tenants"""