Fix new services implementation 1

This commit is contained in:
Urtzi Alfaro
2025-08-13 21:41:00 +02:00
parent 16b8a9d50c
commit 262b3dc9c4
13 changed files with 1702 additions and 1210 deletions

View File

@@ -3,40 +3,27 @@
Client for communicating with Inventory Service
"""
import httpx
import logging
from typing import List, Optional, Dict, Any
from uuid import UUID
from shared.clients.inventory_client import InventoryServiceClient as SharedInventoryClient
from ..core.config import settings
logger = logging.getLogger(__name__)
class InventoryClient:
"""Client for inventory service communication"""
"""Client for inventory service communication via shared client"""
def __init__(self):
self.base_url = settings.INVENTORY_SERVICE_URL
self.timeout = 30.0
self._shared_client = SharedInventoryClient(settings)
async def get_ingredient_by_id(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
"""Get ingredient details from inventory service"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/ingredients/{ingredient_id}",
headers={"X-Tenant-ID": str(tenant_id)}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
return None
else:
logger.error(f"Failed to get ingredient {ingredient_id}: {response.status_code}")
return None
result = await self._shared_client.get_ingredient_by_id(ingredient_id, str(tenant_id))
return result
except Exception as e:
logger.error(f"Error getting ingredient {ingredient_id}: {e}")
return None
@@ -44,19 +31,13 @@ class InventoryClient:
async def get_ingredients_by_ids(self, tenant_id: UUID, ingredient_ids: List[UUID]) -> List[Dict[str, Any]]:
"""Get multiple ingredients by IDs"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/ingredients/batch",
headers={"X-Tenant-ID": str(tenant_id)},
json={"ingredient_ids": [str(id) for id in ingredient_ids]}
)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Failed to get ingredients batch: {response.status_code}")
return []
# For now, get ingredients individually - could be optimized with batch endpoint
results = []
for ingredient_id in ingredient_ids:
ingredient = await self._shared_client.get_ingredient_by_id(ingredient_id, str(tenant_id))
if ingredient:
results.append(ingredient)
return results
except Exception as e:
logger.error(f"Error getting ingredients batch: {e}")
return []
@@ -64,20 +45,16 @@ class InventoryClient:
async def get_ingredient_stock_level(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
"""Get current stock level for ingredient"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/stock/ingredient/{ingredient_id}",
headers={"X-Tenant-ID": str(tenant_id)}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
return None
else:
logger.error(f"Failed to get stock level for {ingredient_id}: {response.status_code}")
return None
stock_entries = await self._shared_client.get_ingredient_stock(ingredient_id, str(tenant_id))
if stock_entries:
# Calculate total available stock from all entries
total_stock = sum(entry.get('available_quantity', 0) for entry in stock_entries)
return {
'ingredient_id': str(ingredient_id),
'total_available': total_stock,
'stock_entries': stock_entries
}
return None
except Exception as e:
logger.error(f"Error getting stock level for {ingredient_id}: {e}")
return None
@@ -114,23 +91,19 @@ class InventoryClient:
) -> Dict[str, Any]:
"""Record ingredient consumption for production"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/stock/consume",
headers={"X-Tenant-ID": str(tenant_id)},
json={
"consumptions": consumptions,
"reference_number": str(production_batch_id),
"movement_type": "production_use"
}
)
consumption_data = {
"consumptions": consumptions,
"reference_number": str(production_batch_id),
"movement_type": "production_use"
}
result = await self._shared_client.consume_stock(consumption_data, str(tenant_id))
if result:
return {"success": True, "data": result}
else:
return {"success": False, "error": "Failed to consume ingredients"}
if response.status_code == 200:
return {"success": True, "data": response.json()}
else:
logger.error(f"Failed to consume ingredients: {response.status_code}")
return {"success": False, "error": response.text}
except Exception as e:
logger.error(f"Error consuming ingredients: {e}")
return {"success": False, "error": str(e)}
@@ -142,19 +115,13 @@ class InventoryClient:
) -> Dict[str, Any]:
"""Add finished product to inventory after production"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/stock/add",
headers={"X-Tenant-ID": str(tenant_id)},
json=product_data
)
result = await self._shared_client.receive_stock(product_data, str(tenant_id))
if result:
return {"success": True, "data": result}
else:
return {"success": False, "error": "Failed to add finished product"}
if response.status_code == 200:
return {"success": True, "data": response.json()}
else:
logger.error(f"Failed to add finished product: {response.status_code}")
return {"success": False, "error": response.text}
except Exception as e:
logger.error(f"Error adding finished product: {e}")
return {"success": False, "error": str(e)}

View File

@@ -10,11 +10,12 @@ from uuid import UUID
from pydantic import BaseModel, Field
import structlog
from app.services.onboarding_import_service import (
OnboardingImportService,
OnboardingImportResult,
InventoryCreationRequest,
get_onboarding_import_service
from app.services.ai_onboarding_service import (
AIOnboardingService,
OnboardingValidationResult,
ProductSuggestionsResult,
OnboardingImportResult,
get_ai_onboarding_service
)
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
@@ -22,16 +23,6 @@ router = APIRouter(tags=["onboarding"])
logger = structlog.get_logger()
class OnboardingAnalysisResponse(BaseModel):
"""Response for onboarding analysis"""
total_products_found: int
inventory_suggestions: List[Dict[str, Any]]
business_model_analysis: Dict[str, Any]
import_job_id: str
status: str
processed_rows: int
errors: List[str]
warnings: List[str]
class InventoryApprovalRequest(BaseModel):
@@ -58,23 +49,22 @@ class SalesImportResponse(BaseModel):
warnings: List[str]
@router.post("/tenants/{tenant_id}/onboarding/analyze", response_model=OnboardingAnalysisResponse)
async def analyze_onboarding_data(
@router.post("/tenants/{tenant_id}/onboarding/validate-file", response_model=FileValidationResponse)
async def validate_onboarding_file(
file: UploadFile = File(..., description="Sales data CSV/Excel file"),
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),
onboarding_service: OnboardingImportService = Depends(get_onboarding_import_service)
onboarding_service: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 1: Analyze uploaded sales data and suggest inventory items
Step 1: Validate uploaded file and extract unique products
This endpoint:
1. Parses the uploaded sales file
2. Extracts unique products and sales metrics
3. Uses AI to classify products and suggest inventory items
4. Analyzes business model (production vs retail)
5. Returns suggestions for user review
1. Validates the file format and content
2. Checks for required columns (date, product, etc.)
3. Extracts unique products from sales data
4. Returns validation results and product list
"""
try:
# Verify tenant access
@@ -89,34 +79,42 @@ async def analyze_onboarding_data(
if not any(file.filename.lower().endswith(ext) for ext in allowed_extensions):
raise HTTPException(status_code=400, detail=f"Unsupported file format. Allowed: {allowed_extensions}")
# Determine file format
file_format = "csv" if file.filename.lower().endswith('.csv') else "excel"
# Read file content
file_content = await file.read()
if not file_content:
raise HTTPException(status_code=400, detail="File is empty")
# Analyze the data
result = await onboarding_service.analyze_sales_data_for_onboarding(
file_content=file_content,
filename=file.filename,
tenant_id=tenant_id,
user_id=UUID(current_user['user_id'])
# Convert bytes to string for CSV
if file_format == "csv":
file_data = file_content.decode('utf-8')
else:
import base64
file_data = base64.b64encode(file_content).decode('utf-8')
# Validate and extract products
result = await onboarding_service.validate_and_extract_products(
file_data=file_data,
file_format=file_format,
tenant_id=tenant_id
)
response = OnboardingAnalysisResponse(
total_products_found=result.total_products_found,
inventory_suggestions=result.inventory_suggestions,
business_model_analysis=result.business_model_analysis,
import_job_id=str(result.import_job_id),
status=result.status,
processed_rows=result.processed_rows,
errors=result.errors,
warnings=result.warnings
response = FileValidationResponse(
is_valid=result.is_valid,
total_records=result.total_records,
unique_products=result.unique_products,
product_list=result.product_list,
validation_errors=result.validation_details.errors,
validation_warnings=result.validation_details.warnings,
summary=result.summary
)
logger.info("Onboarding analysis complete",
logger.info("File validation complete",
filename=file.filename,
products_found=result.total_products_found,
business_model=result.business_model_analysis.get('model'),
is_valid=result.is_valid,
unique_products=result.unique_products,
tenant_id=tenant_id)
return response
@@ -124,9 +122,120 @@ async def analyze_onboarding_data(
except HTTPException:
raise
except Exception as e:
logger.error("Failed onboarding analysis",
logger.error("Failed file validation",
error=str(e), filename=file.filename if file else None, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
@router.post("/tenants/{tenant_id}/onboarding/generate-suggestions", response_model=ProductSuggestionsResponse)
async def generate_inventory_suggestions(
file: UploadFile = File(..., description="Same sales data file from step 1"),
product_list: str = Form(..., description="JSON array of product names to classify"),
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),
onboarding_service: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 2: Generate AI-powered inventory suggestions
This endpoint:
1. Takes the validated file and product list from step 1
2. Uses AI to classify products into inventory categories
3. Analyzes business model (production vs retail)
4. Returns detailed suggestions for user review
"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Parse product list
import json
try:
products = json.loads(product_list)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid product list format: {str(e)}")
if not products:
raise HTTPException(status_code=400, detail="No products provided")
# Determine file format
file_format = "csv" if file.filename.lower().endswith('.csv') else "excel"
# Read file content
file_content = await file.read()
if not file_content:
raise HTTPException(status_code=400, detail="File is empty")
# Convert bytes to string for CSV
if file_format == "csv":
file_data = file_content.decode('utf-8')
else:
import base64
file_data = base64.b64encode(file_content).decode('utf-8')
# Generate suggestions
result = await onboarding_service.generate_inventory_suggestions(
product_list=products,
file_data=file_data,
file_format=file_format,
tenant_id=tenant_id
)
# Convert suggestions to dict format
suggestions_dict = []
for suggestion in result.suggestions:
suggestion_dict = {
"suggestion_id": suggestion.suggestion_id,
"original_name": suggestion.original_name,
"suggested_name": suggestion.suggested_name,
"product_type": suggestion.product_type,
"category": suggestion.category,
"unit_of_measure": suggestion.unit_of_measure,
"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,
"sales_data": suggestion.sales_data
}
suggestions_dict.append(suggestion_dict)
business_model_dict = {
"model": result.business_model_analysis.model,
"confidence": result.business_model_analysis.confidence,
"ingredient_count": result.business_model_analysis.ingredient_count,
"finished_product_count": result.business_model_analysis.finished_product_count,
"ingredient_ratio": result.business_model_analysis.ingredient_ratio,
"recommendations": result.business_model_analysis.recommendations
}
response = ProductSuggestionsResponse(
suggestions=suggestions_dict,
business_model_analysis=business_model_dict,
total_products=result.total_products,
high_confidence_count=result.high_confidence_count,
low_confidence_count=result.low_confidence_count,
processing_time_seconds=result.processing_time_seconds
)
logger.info("AI suggestions generated",
total_products=result.total_products,
business_model=result.business_model_analysis.model,
high_confidence=result.high_confidence_count,
tenant_id=tenant_id)
return response
except HTTPException:
raise
except Exception as e:
logger.error("Failed to generate suggestions",
error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Suggestion generation failed: {str(e)}")
@router.post("/tenants/{tenant_id}/onboarding/create-inventory", response_model=InventoryCreationResponse)
@@ -135,16 +244,16 @@ async def create_inventory_from_suggestions(
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),
onboarding_service: OnboardingImportService = Depends(get_onboarding_import_service)
onboarding_service: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 2: Create inventory items from approved suggestions
Step 3: Create inventory items from approved suggestions
This endpoint:
1. Takes user-approved inventory suggestions
2. Applies any user modifications
1. Takes user-approved inventory suggestions from step 2
2. Applies any user modifications to suggestions
3. Creates inventory items via inventory service
4. Returns creation results
4. Returns creation results for final import step
"""
try:
# Verify tenant access
@@ -154,18 +263,9 @@ async def create_inventory_from_suggestions(
if not request.suggestions:
raise HTTPException(status_code=400, detail="No suggestions provided")
# Convert to internal format
approval_requests = []
for suggestion in request.suggestions:
approval_requests.append(InventoryCreationRequest(
suggestion_id=suggestion.get('suggestion_id'),
approved=suggestion.get('approved', False),
modifications=suggestion.get('modifications', {})
))
# Create inventory items
# Create inventory items using new service
result = await onboarding_service.create_inventory_from_suggestions(
suggestions_approval=approval_requests,
approved_suggestions=request.suggestions,
tenant_id=tenant_id,
user_id=UUID(current_user['user_id'])
)
@@ -199,16 +299,16 @@ async def import_sales_with_inventory(
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),
onboarding_service: OnboardingImportService = Depends(get_onboarding_import_service)
onboarding_service: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 3: Import sales data using created inventory items
Step 4: Final sales data import using created inventory items
This endpoint:
1. Takes the same sales file from step 1
2. Uses the inventory mapping from step 2
3. Imports sales records with proper inventory product references
4. Returns import results
1. Takes the same validated sales file from step 1
2. Uses the inventory mapping from step 3
3. Imports sales records using detailed processing from DataImportService
4. Returns final import results - onboarding complete!
"""
try:
# Verify tenant access
@@ -223,41 +323,51 @@ async def import_sales_with_inventory(
import json
try:
mapping = json.loads(inventory_mapping)
# Convert string UUIDs to UUID objects
inventory_mapping_uuids = {
product_name: UUID(inventory_id)
# Convert to string mapping for the new service
inventory_mapping_dict = {
product_name: str(inventory_id)
for product_name, inventory_id in mapping.items()
}
except (json.JSONDecodeError, ValueError) as e:
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid inventory mapping format: {str(e)}")
# Determine file format
file_format = "csv" if file.filename.lower().endswith('.csv') else "excel"
# Read file content
file_content = await file.read()
if not file_content:
raise HTTPException(status_code=400, detail="File is empty")
# Import sales data
# Convert bytes to string for CSV
if file_format == "csv":
file_data = file_content.decode('utf-8')
else:
import base64
file_data = base64.b64encode(file_content).decode('utf-8')
# Import sales data using new service
result = await onboarding_service.import_sales_data_with_inventory(
file_content=file_content,
filename=file.filename,
file_data=file_data,
file_format=file_format,
inventory_mapping=inventory_mapping_dict,
tenant_id=tenant_id,
user_id=UUID(current_user['user_id']),
inventory_mapping=inventory_mapping_uuids
filename=file.filename
)
response = SalesImportResponse(
import_job_id=str(result.import_job_id),
status=result.status,
processed_rows=result.processed_rows,
successful_imports=result.successful_imports,
failed_imports=result.failed_imports,
errors=result.errors,
warnings=result.warnings
import_job_id="onboarding-" + str(tenant_id), # Generate a simple job ID
status="completed" if result.success else "failed",
processed_rows=result.import_details.records_processed,
successful_imports=result.import_details.records_created,
failed_imports=result.import_details.records_failed,
errors=[error.get("message", str(error)) for error in result.import_details.errors],
warnings=[warning.get("message", str(warning)) for warning in result.import_details.warnings]
)
logger.info("Sales import complete",
successful=result.successful_imports,
failed=result.failed_imports,
successful=result.import_details.records_created,
failed=result.import_details.records_failed,
filename=file.filename,
tenant_id=tenant_id)

View File

@@ -0,0 +1,627 @@
# services/sales/app/services/ai_onboarding_service.py
"""
AI-Powered Onboarding Service
Handles the complete onboarding flow: File validation -> Product extraction -> Inventory suggestions -> Data processing
"""
import pandas as pd
import structlog
from typing import List, Dict, Any, Optional
from uuid import UUID, uuid4
from dataclasses import dataclass
import asyncio
from app.services.data_import_service import DataImportService, SalesValidationResult, SalesImportResult
from app.services.inventory_client import InventoryServiceClient
from app.core.database import get_db_transaction
logger = structlog.get_logger()
@dataclass
class ProductSuggestion:
"""Single product suggestion from AI classification"""
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: Optional[int] = None
requires_refrigeration: bool = False
requires_freezing: bool = False
is_seasonal: bool = False
suggested_supplier: Optional[str] = None
notes: Optional[str] = None
sales_data: Optional[Dict[str, Any]] = None
@dataclass
class BusinessModelAnalysis:
"""Business model analysis results"""
model: str # production, retail, hybrid
confidence: float
ingredient_count: int
finished_product_count: int
ingredient_ratio: float
recommendations: List[str]
@dataclass
class OnboardingValidationResult:
"""Result of onboarding file validation step"""
is_valid: bool
total_records: int
unique_products: int
validation_details: SalesValidationResult
product_list: List[str]
summary: Dict[str, Any]
@dataclass
class ProductSuggestionsResult:
"""Result of AI product classification step"""
suggestions: List[ProductSuggestion]
business_model_analysis: BusinessModelAnalysis
total_products: int
high_confidence_count: int
low_confidence_count: int
processing_time_seconds: float
@dataclass
class OnboardingImportResult:
"""Result of final data import step"""
success: bool
import_details: SalesImportResult
inventory_items_created: int
inventory_creation_errors: List[str]
final_summary: Dict[str, Any]
class AIOnboardingService:
"""
Unified AI-powered onboarding service that orchestrates the complete flow:
1. File validation and product extraction
2. AI-powered inventory suggestions
3. User confirmation and inventory creation
4. Final sales data import
"""
def __init__(self):
self.data_import_service = DataImportService()
self.inventory_client = InventoryServiceClient()
# ================================================================
# STEP 1: FILE VALIDATION AND PRODUCT EXTRACTION
# ================================================================
async def validate_and_extract_products(
self,
file_data: str,
file_format: str,
tenant_id: UUID
) -> OnboardingValidationResult:
"""
Step 1: Validate uploaded file and extract unique products
This uses the detailed validation from data_import_service
"""
try:
logger.info("Starting onboarding validation and product extraction",
file_format=file_format, tenant_id=tenant_id)
# Use data_import_service for detailed validation
validation_data = {
"tenant_id": str(tenant_id),
"data": file_data,
"data_format": file_format,
"validate_only": True,
"source": "ai_onboarding"
}
validation_result = await self.data_import_service.validate_import_data(validation_data)
# Extract unique products if validation passes
product_list = []
unique_products = 0
if validation_result.is_valid and file_format.lower() == "csv":
try:
# Parse CSV to extract unique products
import csv
import io
reader = csv.DictReader(io.StringIO(file_data))
rows = list(reader)
# Use data_import_service column detection
column_mapping = self.data_import_service._detect_columns(list(rows[0].keys()) if rows else [])
if column_mapping.get('product'):
product_column = column_mapping['product']
# Extract and clean unique products
products_raw = [row.get(product_column, '').strip() for row in rows if row.get(product_column, '').strip()]
# Clean product names using data_import_service method
products_cleaned = [
self.data_import_service._clean_product_name(product)
for product in products_raw
]
# Get unique products
product_list = list(set([p for p in products_cleaned if p and p != "Producto sin nombre"]))
unique_products = len(product_list)
logger.info("Extracted unique products",
total_rows=len(rows), unique_products=unique_products)
except Exception as e:
logger.error("Failed to extract products", error=str(e))
# Don't fail validation just because product extraction failed
pass
result = OnboardingValidationResult(
is_valid=validation_result.is_valid,
total_records=validation_result.total_records,
unique_products=unique_products,
validation_details=validation_result,
product_list=product_list,
summary={
"status": "valid" if validation_result.is_valid else "invalid",
"file_format": file_format,
"total_records": validation_result.total_records,
"unique_products": unique_products,
"ready_for_ai_classification": validation_result.is_valid and unique_products > 0,
"next_step": "ai_classification" if validation_result.is_valid and unique_products > 0 else "fix_validation_errors"
}
)
logger.info("Onboarding validation completed",
is_valid=result.is_valid,
unique_products=unique_products,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Onboarding validation failed", error=str(e), tenant_id=tenant_id)
return OnboardingValidationResult(
is_valid=False,
total_records=0,
unique_products=0,
validation_details=SalesValidationResult(
is_valid=False,
total_records=0,
valid_records=0,
invalid_records=0,
errors=[{
"type": "system_error",
"message": f"Onboarding validation error: {str(e)}",
"field": None,
"row": None,
"code": "ONBOARDING_VALIDATION_ERROR"
}],
warnings=[],
summary={}
),
product_list=[],
summary={
"status": "error",
"error_message": str(e),
"next_step": "retry_upload"
}
)
# ================================================================
# STEP 2: AI PRODUCT CLASSIFICATION
# ================================================================
async def generate_inventory_suggestions(
self,
product_list: List[str],
file_data: str,
file_format: str,
tenant_id: UUID
) -> ProductSuggestionsResult:
"""
Step 2: Generate AI-powered inventory suggestions for products
"""
import time
start_time = time.time()
try:
logger.info("Starting AI inventory suggestions",
product_count=len(product_list), tenant_id=tenant_id)
if not product_list:
raise ValueError("No products provided for classification")
# Analyze sales data for each product to provide context
product_analysis = await self._analyze_product_sales_data(
product_list, file_data, file_format
)
# Prepare products for classification
products_for_classification = []
for product_name in product_list:
sales_data = product_analysis.get(product_name, {})
products_for_classification.append({
"product_name": product_name,
"sales_volume": sales_data.get("total_quantity"),
"sales_data": sales_data
})
# Call inventory service for AI classification
classification_result = await self.inventory_client.classify_products_batch(
products_for_classification, tenant_id
)
if not classification_result or "suggestions" not in classification_result:
raise ValueError("Invalid classification response from inventory service")
suggestions_raw = classification_result["suggestions"]
business_model_raw = classification_result.get("business_model_analysis", {})
# Convert to dataclass objects
suggestions = []
for suggestion_data in suggestions_raw:
suggestion = ProductSuggestion(
suggestion_id=suggestion_data.get("suggestion_id", str(uuid4())),
original_name=suggestion_data["original_name"],
suggested_name=suggestion_data["suggested_name"],
product_type=suggestion_data["product_type"],
category=suggestion_data["category"],
unit_of_measure=suggestion_data["unit_of_measure"],
confidence_score=suggestion_data["confidence_score"],
estimated_shelf_life_days=suggestion_data.get("estimated_shelf_life_days"),
requires_refrigeration=suggestion_data.get("requires_refrigeration", False),
requires_freezing=suggestion_data.get("requires_freezing", False),
is_seasonal=suggestion_data.get("is_seasonal", False),
suggested_supplier=suggestion_data.get("suggested_supplier"),
notes=suggestion_data.get("notes"),
sales_data=product_analysis.get(suggestion_data["original_name"])
)
suggestions.append(suggestion)
business_model = BusinessModelAnalysis(
model=business_model_raw.get("model", "unknown"),
confidence=business_model_raw.get("confidence", 0.0),
ingredient_count=business_model_raw.get("ingredient_count", 0),
finished_product_count=business_model_raw.get("finished_product_count", 0),
ingredient_ratio=business_model_raw.get("ingredient_ratio", 0.0),
recommendations=business_model_raw.get("recommendations", [])
)
# Calculate confidence metrics
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)
processing_time = time.time() - start_time
result = ProductSuggestionsResult(
suggestions=suggestions,
business_model_analysis=business_model,
total_products=len(suggestions),
high_confidence_count=high_confidence_count,
low_confidence_count=low_confidence_count,
processing_time_seconds=processing_time
)
logger.info("AI inventory suggestions completed",
total_suggestions=len(suggestions),
business_model=business_model.model,
high_confidence=high_confidence_count,
processing_time=processing_time,
tenant_id=tenant_id)
return result
except Exception as e:
processing_time = time.time() - start_time
logger.error("AI inventory suggestions failed",
error=str(e), tenant_id=tenant_id)
# Return fallback suggestions
fallback_suggestions = [
ProductSuggestion(
suggestion_id=str(uuid4()),
original_name=product_name,
suggested_name=product_name.title(),
product_type="finished_product",
category="other_products",
unit_of_measure="units",
confidence_score=0.3,
notes="Fallback suggestion - requires manual review"
)
for product_name in product_list
]
return ProductSuggestionsResult(
suggestions=fallback_suggestions,
business_model_analysis=BusinessModelAnalysis(
model="unknown",
confidence=0.0,
ingredient_count=0,
finished_product_count=len(fallback_suggestions),
ingredient_ratio=0.0,
recommendations=["Manual review required for all products"]
),
total_products=len(fallback_suggestions),
high_confidence_count=0,
low_confidence_count=len(fallback_suggestions),
processing_time_seconds=processing_time
)
# ================================================================
# STEP 3: INVENTORY CREATION (after user confirmation)
# ================================================================
async def create_inventory_from_suggestions(
self,
approved_suggestions: List[Dict[str, Any]],
tenant_id: UUID,
user_id: UUID
) -> Dict[str, Any]:
"""
Step 3: Create inventory items from user-approved suggestions
"""
try:
logger.info("Creating inventory from approved suggestions",
approved_count=len(approved_suggestions), tenant_id=tenant_id)
created_items = []
failed_items = []
for approval in approved_suggestions:
suggestion_id = approval.get("suggestion_id")
is_approved = approval.get("approved", False)
modifications = approval.get("modifications", {})
if not is_approved:
continue
try:
# Build inventory item data from suggestion and modifications
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"),
"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"
}
# Create inventory item via inventory service
created_item = await self.inventory_client.create_ingredient(
inventory_data, str(tenant_id)
)
if created_item:
created_items.append({
"suggestion_id": suggestion_id,
"inventory_item": created_item,
"original_name": approval.get("original_name")
})
logger.info("Created inventory item",
item_name=inventory_data["name"],
suggestion_id=suggestion_id)
else:
failed_items.append({
"suggestion_id": suggestion_id,
"error": "Failed to create inventory item - no response"
})
except Exception as e:
logger.error("Failed to create inventory item",
error=str(e), suggestion_id=suggestion_id)
failed_items.append({
"suggestion_id": suggestion_id,
"error": str(e)
})
success_rate = len(created_items) / max(1, len(approved_suggestions)) * 100
result = {
"created_items": created_items,
"failed_items": failed_items,
"total_approved": len(approved_suggestions),
"successful_creations": len(created_items),
"failed_creations": len(failed_items),
"success_rate": success_rate,
"ready_for_import": len(created_items) > 0
}
logger.info("Inventory creation completed",
created=len(created_items),
failed=len(failed_items),
success_rate=success_rate,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Inventory creation failed", error=str(e), tenant_id=tenant_id)
raise
# ================================================================
# STEP 4: FINAL DATA IMPORT
# ================================================================
async def import_sales_data_with_inventory(
self,
file_data: str,
file_format: str,
inventory_mapping: Dict[str, str], # original_product_name -> inventory_item_id
tenant_id: UUID,
filename: Optional[str] = None
) -> OnboardingImportResult:
"""
Step 4: Import sales data using the detailed processing from data_import_service
"""
try:
logger.info("Starting final sales data import with inventory mapping",
mappings_count=len(inventory_mapping), tenant_id=tenant_id)
# Use data_import_service for the actual import processing
import_result = await self.data_import_service.process_import(
str(tenant_id), file_data, file_format, filename
)
result = OnboardingImportResult(
success=import_result.success,
import_details=import_result,
inventory_items_created=len(inventory_mapping),
inventory_creation_errors=[],
final_summary={
"status": "completed" if import_result.success else "failed",
"total_records": import_result.records_processed,
"successful_imports": import_result.records_created,
"failed_imports": import_result.records_failed,
"inventory_items": len(inventory_mapping),
"processing_time": import_result.processing_time_seconds,
"onboarding_complete": import_result.success
}
)
logger.info("Final sales data import completed",
success=import_result.success,
records_created=import_result.records_created,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Final sales data import failed", error=str(e), tenant_id=tenant_id)
return OnboardingImportResult(
success=False,
import_details=SalesImportResult(
success=False,
records_processed=0,
records_created=0,
records_updated=0,
records_failed=0,
errors=[{
"type": "import_error",
"message": f"Import failed: {str(e)}",
"field": None,
"row": None,
"code": "FINAL_IMPORT_ERROR"
}],
warnings=[],
processing_time_seconds=0.0
),
inventory_items_created=len(inventory_mapping),
inventory_creation_errors=[str(e)],
final_summary={
"status": "failed",
"error_message": str(e),
"onboarding_complete": False
}
)
# ================================================================
# HELPER METHODS
# ================================================================
async def _analyze_product_sales_data(
self,
product_list: List[str],
file_data: str,
file_format: str
) -> Dict[str, Dict[str, Any]]:
"""Analyze sales data for each product to provide context for AI classification"""
try:
if file_format.lower() != "csv":
return {}
import csv
import io
reader = csv.DictReader(io.StringIO(file_data))
rows = list(reader)
if not rows:
return {}
# Use data_import_service column detection
column_mapping = self.data_import_service._detect_columns(list(rows[0].keys()))
if not column_mapping.get('product'):
return {}
product_column = column_mapping['product']
quantity_column = column_mapping.get('quantity')
revenue_column = column_mapping.get('revenue')
date_column = column_mapping.get('date')
# Analyze each product
product_analysis = {}
for product_name in product_list:
# Find all rows for this product
product_rows = [
row for row in rows
if self.data_import_service._clean_product_name(row.get(product_column, '')) == product_name
]
if not product_rows:
continue
# Calculate metrics
total_quantity = 0
total_revenue = 0
sales_count = len(product_rows)
for row in product_rows:
try:
# Quantity
qty_raw = row.get(quantity_column, 1)
if qty_raw and str(qty_raw).strip():
qty = int(float(str(qty_raw).replace(',', '.')))
total_quantity += qty
else:
total_quantity += 1
# Revenue
if revenue_column:
rev_raw = row.get(revenue_column)
if rev_raw and str(rev_raw).strip():
rev = float(str(rev_raw).replace(',', '.').replace('', '').replace('$', '').strip())
total_revenue += rev
except:
continue
avg_quantity = total_quantity / sales_count if sales_count > 0 else 0
avg_revenue = total_revenue / sales_count if sales_count > 0 else 0
avg_unit_price = total_revenue / total_quantity if total_quantity > 0 else 0
product_analysis[product_name] = {
"total_quantity": total_quantity,
"total_revenue": total_revenue,
"sales_count": sales_count,
"avg_quantity_per_sale": avg_quantity,
"avg_revenue_per_sale": avg_revenue,
"avg_unit_price": avg_unit_price
}
return product_analysis
except Exception as e:
logger.warning("Failed to analyze product sales data", error=str(e))
return {}
# Factory function for dependency injection
def get_ai_onboarding_service() -> AIOnboardingService:
"""Get AI onboarding service instance"""
return AIOnboardingService()

View File

@@ -4,122 +4,83 @@ Inventory Service Client - Inter-service communication
Handles communication with the inventory service to fetch product data
"""
import httpx
import structlog
from typing import Dict, Any, List, Optional
from uuid import UUID
from shared.clients.inventory_client import InventoryServiceClient as SharedInventoryClient
from app.core.config import settings
logger = structlog.get_logger()
class InventoryServiceClient:
"""Client for communicating with the inventory service"""
"""Client for communicating with the inventory service via shared client"""
def __init__(self):
self.base_url = settings.INVENTORY_SERVICE_URL
self.timeout = 30.0
self._shared_client = SharedInventoryClient(settings)
async def classify_products_batch(self, product_list: Dict[str, Any], tenant_id: UUID) -> Optional[Dict[str, Any]]:
"""Get product details from inventory service by ID"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/tenants/{tenant_id}/inventory/classify-products-batch",
headers=self._get_headers(),
product_list=product_list
)
if response.status_code == 200:
product_data = response.json()
logger.info("Retrieved product from inventory service",
tenant_id=tenant_id)
return product_data
elif response.status_code == 404:
logger.warning("Product not found in inventory service",
tenant_id=tenant_id)
return None
else:
logger.error("Failed to fetch product from inventory service",
status_code=response.status_code,
tenant_id=tenant_id)
return None
except httpx.TimeoutException:
logger.error("Timeout fetching product from inventory service",
tenant_id=tenant_id)
return None
except Exception as e:
logger.error("Error communicating with inventory service",
error=str(e), tenant_id=tenant_id)
return None
async def classify_products_batch(self, product_list: List[Dict[str, Any]], tenant_id: UUID) -> Optional[Dict[str, Any]]:
"""Classify multiple products for inventory creation"""
try:
# Convert product_list to expected format for shared client
products = []
for item in product_list:
if isinstance(item, str):
# If it's just a product name
products.append({"product_name": item})
elif isinstance(item, dict):
# If it's already a dict, ensure required fields
product_data = {
"product_name": item.get("product_name", item.get("name", str(item))),
"sales_volume": item.get("sales_volume", item.get("total_quantity"))
}
products.append(product_data)
result = await self._shared_client.classify_products_batch(products, str(tenant_id))
if result:
logger.info("Classified products batch",
count=len(products), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error in batch product classification",
error=str(e), tenant_id=tenant_id)
return None
async def get_product_by_id(self, product_id: UUID, tenant_id: UUID) -> Optional[Dict[str, Any]]:
"""Get product details from inventory service by ID"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/tenants/{tenant_id}/ingredients/{product_id}",
headers=self._get_headers()
)
if response.status_code == 200:
product_data = response.json()
logger.info("Retrieved product from inventory service",
product_id=product_id, tenant_id=tenant_id)
return product_data
elif response.status_code == 404:
logger.warning("Product not found in inventory service",
product_id=product_id, tenant_id=tenant_id)
return None
else:
logger.error("Failed to fetch product from inventory service",
status_code=response.status_code,
product_id=product_id, tenant_id=tenant_id)
return None
except httpx.TimeoutException:
logger.error("Timeout fetching product from inventory service",
product_id=product_id, tenant_id=tenant_id)
return None
except Exception as e:
logger.error("Error communicating with inventory service",
error=str(e), product_id=product_id, tenant_id=tenant_id)
return None
"""Get product details from inventory service by ID"""
try:
result = await self._shared_client.get_ingredient_by_id(product_id, str(tenant_id))
if result:
logger.info("Retrieved product from inventory service",
product_id=product_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error fetching product by ID",
error=str(e), product_id=product_id, tenant_id=tenant_id)
return None
async def get_product_by_sku(self, sku: str, tenant_id: UUID) -> Optional[Dict[str, Any]]:
"""Get product details from inventory service by SKU"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/tenants/{tenant_id}/ingredients",
params={"sku": sku, "limit": 1},
headers=self._get_headers()
)
# Search for product by SKU using shared client
products = await self._shared_client.search_ingredients(
str(tenant_id), search=sku, limit=1
)
if products:
product_data = products[0]
logger.info("Retrieved product by SKU from inventory service",
sku=sku, tenant_id=tenant_id)
return product_data
else:
logger.warning("Product not found by SKU in inventory service",
sku=sku, tenant_id=tenant_id)
return None
if response.status_code == 200:
data = response.json()
products = data.get("items", [])
if products:
product_data = products[0]
logger.info("Retrieved product by SKU from inventory service",
sku=sku, tenant_id=tenant_id)
return product_data
else:
logger.warning("Product not found by SKU in inventory service",
sku=sku, tenant_id=tenant_id)
return None
else:
logger.error("Failed to fetch product by SKU from inventory service",
status_code=response.status_code,
sku=sku, tenant_id=tenant_id)
return None
except httpx.TimeoutException:
logger.error("Timeout fetching product by SKU from inventory service",
sku=sku, tenant_id=tenant_id)
return None
except Exception as e:
logger.error("Error communicating with inventory service for SKU",
logger.error("Error fetching product by SKU",
error=str(e), sku=sku, tenant_id=tenant_id)
return None
@@ -127,38 +88,16 @@ class InventoryServiceClient:
product_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""Search products in inventory service"""
try:
params = {
"search": search_term,
"limit": 50
}
if product_type:
params["product_type"] = product_type
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/tenants/{tenant_id}/ingredients",
params=params,
headers=self._get_headers()
)
if response.status_code == 200:
data = response.json()
products = data.get("items", [])
logger.info("Searched products in inventory service",
search_term=search_term, count=len(products), tenant_id=tenant_id)
return products
else:
logger.error("Failed to search products in inventory service",
status_code=response.status_code,
search_term=search_term, tenant_id=tenant_id)
return []
except httpx.TimeoutException:
logger.error("Timeout searching products in inventory service",
search_term=search_term, tenant_id=tenant_id)
return []
products = await self._shared_client.search_ingredients(
str(tenant_id), search=search_term, limit=50
)
logger.info("Searched products in inventory service",
search_term=search_term, count=len(products), tenant_id=tenant_id)
return products
except Exception as e:
logger.error("Error searching products in inventory service",
logger.error("Error searching products",
error=str(e), search_term=search_term, tenant_id=tenant_id)
return []
@@ -166,55 +105,18 @@ class InventoryServiceClient:
product_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get products by category from inventory service"""
try:
params = {
"limit": 100
}
if product_type == "ingredient":
params["ingredient_category"] = category
elif product_type == "finished_product":
params["product_category"] = category
else:
# Search in both categories if type not specified
params["category"] = category
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/tenants/{tenant_id}/ingredients",
params=params,
headers=self._get_headers()
)
if response.status_code == 200:
data = response.json()
products = data.get("items", [])
logger.info("Retrieved products by category from inventory service",
category=category, count=len(products), tenant_id=tenant_id)
return products
else:
logger.error("Failed to fetch products by category from inventory service",
status_code=response.status_code,
category=category, tenant_id=tenant_id)
return []
except httpx.TimeoutException:
logger.error("Timeout fetching products by category from inventory service",
category=category, tenant_id=tenant_id)
return []
products = await self._shared_client.search_ingredients(
str(tenant_id), category=category, limit=100
)
logger.info("Retrieved products by category from inventory service",
category=category, count=len(products), tenant_id=tenant_id)
return products
except Exception as e:
logger.error("Error fetching products by category from inventory service",
logger.error("Error fetching products by category",
error=str(e), category=category, tenant_id=tenant_id)
return []
# Cache synchronization removed - no longer needed with pure inventory reference approach
def _get_headers(self) -> Dict[str, str]:
"""Get headers for inventory service requests"""
return {
"Content-Type": "application/json",
"X-Service-Name": "sales-service",
# Add authentication headers if needed
}
# Dependency injection
async def get_inventory_client() -> InventoryServiceClient:

View File

@@ -1,446 +0,0 @@
# services/sales/app/services/onboarding_import_service.py
"""
Onboarding Data Import Service
Handles historical sales data import with automated inventory creation
"""
import pandas as pd
import structlog
from typing import List, Dict, Any, Optional, Tuple
from uuid import UUID, uuid4
from datetime import datetime, timezone
from dataclasses import dataclass, asdict
import asyncio
from app.services.inventory_client import InventoryServiceClient
from app.services.data_import_service import DataImportService
from app.models.sales import SalesData
from app.core.database import get_db_transaction
from app.repositories.sales_repository import SalesRepository
logger = structlog.get_logger()
@dataclass
class OnboardingImportResult:
"""Result of onboarding import process"""
total_products_found: int
inventory_suggestions: List[Dict[str, Any]]
business_model_analysis: Dict[str, Any]
import_job_id: UUID
status: str
processed_rows: int
successful_imports: int
failed_imports: int
errors: List[str]
warnings: List[str]
@dataclass
class InventoryCreationRequest:
"""Request to create inventory item from suggestion"""
suggestion_id: str
approved: bool
modifications: Dict[str, Any] # User modifications to the suggestion
class OnboardingImportService:
"""Service for handling onboarding data import with inventory automation"""
def __init__(self):
self.inventory_client = InventoryServiceClient()
self.data_import_service = DataImportService()
async def analyze_sales_data_for_onboarding(
self,
file_content: bytes,
filename: str,
tenant_id: UUID,
user_id: UUID
) -> OnboardingImportResult:
"""Analyze uploaded sales data and suggest inventory items"""
try:
logger.info("Starting onboarding analysis", filename=filename, tenant_id=tenant_id)
# Parse the uploaded file
df = await self._parse_uploaded_file(file_content, filename)
# Extract unique products and their sales volumes
product_analysis = self._analyze_products_from_sales(df)
# Get product suggestions from inventory service
inventory_suggestions = await self._get_inventory_suggestions(
product_analysis, tenant_id
)
# Analyze business model
business_model = self._analyze_business_model(inventory_suggestions)
# Create import job for tracking
import_job_id = await self._create_import_job(
filename, tenant_id, user_id, len(df)
)
result = OnboardingImportResult(
total_products_found=len(product_analysis),
inventory_suggestions=inventory_suggestions,
business_model_analysis=business_model,
import_job_id=import_job_id,
status="analysis_complete",
processed_rows=len(df),
successful_imports=0, # Will be updated when user confirms
failed_imports=0,
errors=[],
warnings=self._generate_warnings(df, inventory_suggestions)
)
logger.info("Onboarding analysis complete",
products_found=len(product_analysis),
business_model=business_model.get('model'),
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Failed onboarding analysis", error=str(e), tenant_id=tenant_id)
raise
async def create_inventory_from_suggestions(
self,
suggestions_approval: List[InventoryCreationRequest],
tenant_id: UUID,
user_id: UUID
) -> Dict[str, Any]:
"""Create inventory items from approved suggestions"""
try:
created_items = []
failed_items = []
for request in suggestions_approval:
if request.approved:
try:
# Find the original suggestion
suggestion = self._find_suggestion_by_id(request.suggestion_id)
if not suggestion:
failed_items.append({
'suggestion_id': request.suggestion_id,
'error': 'Suggestion not found'
})
continue
# Apply user modifications
final_item_data = self._apply_modifications(suggestion, request.modifications)
# Create inventory item via inventory service
created_item = await self._create_inventory_item(
final_item_data, tenant_id, user_id
)
created_items.append(created_item)
except Exception as e:
logger.error("Failed to create inventory item",
error=str(e), suggestion_id=request.suggestion_id)
failed_items.append({
'suggestion_id': request.suggestion_id,
'error': str(e)
})
logger.info("Inventory creation complete",
created=len(created_items), failed=len(failed_items), tenant_id=tenant_id)
return {
'created_items': created_items,
'failed_items': failed_items,
'total_approved': len([r for r in suggestions_approval if r.approved]),
'success_rate': len(created_items) / max(1, len([r for r in suggestions_approval if r.approved]))
}
except Exception as e:
logger.error("Failed inventory creation", error=str(e), tenant_id=tenant_id)
raise
async def import_sales_data_with_inventory(
self,
file_content: bytes,
filename: str,
tenant_id: UUID,
user_id: UUID,
inventory_mapping: Dict[str, UUID] # product_name -> inventory_product_id
) -> OnboardingImportResult:
"""Import sales data using created inventory items"""
try:
logger.info("Starting sales import with inventory mapping",
filename=filename, products_mapped=len(inventory_mapping), tenant_id=tenant_id)
# Parse the file again
df = await self._parse_uploaded_file(file_content, filename)
# Add inventory product IDs to the data
df_with_inventory = self._map_products_to_inventory(df, inventory_mapping)
# Import the sales data using the standard import service
import_result = await self._import_sales_with_inventory_ids(
df_with_inventory, tenant_id, user_id, filename
)
result = OnboardingImportResult(
total_products_found=len(inventory_mapping),
inventory_suggestions=[], # Already processed
business_model_analysis={}, # Already analyzed
import_job_id=import_result['job_id'],
status="import_complete",
processed_rows=import_result['processed_rows'],
successful_imports=import_result['successful_imports'],
failed_imports=import_result['failed_imports'],
errors=import_result.get('errors', []),
warnings=import_result.get('warnings', [])
)
logger.info("Sales import complete",
successful=result.successful_imports,
failed=result.failed_imports,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Failed sales import", error=str(e), tenant_id=tenant_id)
raise
async def _parse_uploaded_file(self, file_content: bytes, filename: str) -> pd.DataFrame:
"""Parse uploaded CSV/Excel file"""
try:
if filename.endswith('.csv'):
# Try different encodings
for encoding in ['utf-8', 'latin-1', 'cp1252']:
try:
df = pd.read_csv(io.BytesIO(file_content), encoding=encoding)
break
except UnicodeDecodeError:
continue
else:
raise ValueError("Could not decode CSV file with any supported encoding")
elif filename.endswith(('.xlsx', '.xls')):
df = pd.read_excel(io.BytesIO(file_content))
else:
raise ValueError(f"Unsupported file format: {filename}")
# Validate required columns exist
required_columns = ['product_name', 'quantity_sold', 'revenue', 'date']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
raise ValueError(f"Missing required columns: {missing_columns}")
# Clean the data
df = df.dropna(subset=['product_name', 'quantity_sold', 'revenue'])
df['date'] = pd.to_datetime(df['date'], errors='coerce')
df = df.dropna(subset=['date'])
logger.info("File parsed successfully", rows=len(df), columns=list(df.columns))
return df
except Exception as e:
logger.error("Failed to parse file", error=str(e), filename=filename)
raise
def _analyze_products_from_sales(self, df: pd.DataFrame) -> Dict[str, Dict[str, Any]]:
"""Extract and analyze products from sales data"""
# Group by product name and calculate metrics
product_stats = df.groupby('product_name').agg({
'quantity_sold': ['sum', 'mean', 'count'],
'revenue': ['sum', 'mean'],
'date': ['min', 'max']
}).round(2)
# Flatten column names
product_stats.columns = ['_'.join(col).strip() for col in product_stats.columns.values]
# Convert to dictionary with analysis
products = {}
for product_name in product_stats.index:
stats = product_stats.loc[product_name]
products[product_name] = {
'name': product_name,
'total_quantity': float(stats['quantity_sold_sum']),
'avg_quantity_per_sale': float(stats['quantity_sold_mean']),
'total_sales_count': int(stats['quantity_sold_count']),
'total_revenue': float(stats['revenue_sum']),
'avg_revenue_per_sale': float(stats['revenue_mean']),
'first_sale_date': stats['date_min'],
'last_sale_date': stats['date_max'],
'avg_unit_price': float(stats['revenue_sum'] / stats['quantity_sold_sum']) if stats['quantity_sold_sum'] > 0 else 0
}
logger.info("Product analysis complete", unique_products=len(products))
return products
async def _get_inventory_suggestions(
self,
product_analysis: Dict[str, Dict[str, Any]],
tenant_id: UUID
) -> List[Dict[str, Any]]:
"""Get inventory suggestions from inventory service"""
try:
# Call inventory service classification API
product_names = list(product_analysis.keys())
suggestions = []
suggestions = await self.inventory_client.classify_products_batch(product_names)
return suggestions
except Exception as e:
logger.error("Failed to get inventory suggestions", error=str(e))
# Return fallback suggestions for all products
return [self._create_fallback_suggestion(name, stats)
for name, stats in product_analysis.items()]
def _create_fallback_suggestion(self, product_name: str, stats: Dict[str, Any]) -> Dict[str, Any]:
"""Create fallback suggestion when AI classification fails"""
return {
'suggestion_id': str(uuid4()),
'original_name': product_name,
'suggested_name': product_name.title(),
'product_type': 'finished_product',
'category': 'other_products',
'unit_of_measure': 'units',
'confidence_score': 0.3,
'estimated_shelf_life_days': 3,
'requires_refrigeration': False,
'requires_freezing': False,
'is_seasonal': False,
'notes': 'Fallback suggestion - requires manual review',
'original_sales_data': stats
}
def _analyze_business_model(self, suggestions: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Analyze business model from suggestions"""
if not suggestions:
return {'model': 'unknown', 'confidence': 0.0}
ingredient_count = sum(1 for s in suggestions if s.get('product_type') == 'ingredient')
finished_count = sum(1 for s in suggestions if s.get('product_type') == 'finished_product')
total = len(suggestions)
ingredient_ratio = ingredient_count / total if total > 0 else 0
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)
return {
'model': model,
'confidence': confidence,
'ingredient_count': ingredient_count,
'finished_product_count': finished_count,
'ingredient_ratio': ingredient_ratio,
'recommendations': self._get_model_recommendations(model)
}
def _get_model_recommendations(self, model: str) -> List[str]:
"""Get recommendations based on business model"""
recommendations = {
'production': [
'Set up supplier relationships for ingredients',
'Configure recipe management',
'Enable production cost tracking',
'Set up ingredient inventory alerts'
],
'retail': [
'Configure central baker relationships',
'Set up delivery tracking',
'Enable freshness monitoring',
'Focus on sales forecasting'
],
'hybrid': [
'Configure both production and retail features',
'Set up flexible inventory management',
'Enable comprehensive analytics'
]
}
return recommendations.get(model, [])
async def _create_import_job(
self,
filename: str,
tenant_id: UUID,
user_id: UUID,
total_rows: int
) -> UUID:
"""Create import job for tracking"""
try:
async with get_db_transaction() as db:
from app.models.sales import SalesImportJob
job = SalesImportJob(
id=uuid4(),
tenant_id=tenant_id,
filename=filename,
import_type='onboarding_csv',
status='analyzing',
total_rows=total_rows,
created_by=user_id
)
db.add(job)
await db.commit()
logger.info("Import job created", job_id=job.id, tenant_id=tenant_id)
return job.id
except Exception as e:
logger.error("Failed to create import job", error=str(e))
return uuid4() # Return dummy ID if job creation fails
def _generate_warnings(self, df: pd.DataFrame, suggestions: List[Dict[str, Any]]) -> List[str]:
"""Generate warnings about data quality"""
warnings = []
# Check for low confidence suggestions
low_confidence = [s for s in suggestions if s.get('confidence_score', 1.0) < 0.6]
if low_confidence:
warnings.append(f"{len(low_confidence)} products have low classification confidence and may need manual review")
# Check for missing data
missing_prices = df[df['revenue'].isna() | (df['revenue'] == 0)].shape[0]
if missing_prices > 0:
warnings.append(f"{missing_prices} sales records have missing or zero revenue")
# Check for old data
latest_date = df['date'].max()
if pd.Timestamp.now() - latest_date > pd.Timedelta(days=90):
warnings.append("Sales data appears to be more than 90 days old")
return warnings
# Additional helper methods would be implemented here...
# _find_suggestion_by_id, _apply_modifications, _create_inventory_item, etc.
# Dependency injection
def get_onboarding_import_service() -> OnboardingImportService:
"""Get onboarding import service instance"""
return OnboardingImportService()