Fix new services implementation 2
This commit is contained in:
@@ -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'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user