Files
bakery-ia/services/sales/app/api/onboarding.py
2025-08-14 13:26:59 +02:00

499 lines
20 KiB
Python

# services/sales/app/api/onboarding.py
"""
Onboarding API Endpoints
Handles sales data import with automated inventory creation
"""
from fastapi import APIRouter, Depends, HTTPException, Path, UploadFile, File, Form
from typing import List, Dict, Any, Optional
from uuid import UUID
from pydantic import BaseModel, Field
import structlog
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
router = APIRouter(tags=["onboarding"])
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")
class InventoryCreationResponse(BaseModel):
"""Response for inventory creation"""
created_items: List[Dict[str, Any]]
failed_items: List[Dict[str, Any]]
total_approved: int
success_rate: float
class SalesImportResponse(BaseModel):
"""Response for final sales import"""
import_job_id: str
status: str
processed_rows: int
successful_imports: int
failed_imports: int
errors: List[str]
warnings: List[str]
@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: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 1: Validate uploaded file and extract unique products
This endpoint:
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
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Validate file
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
allowed_extensions = ['.csv', '.xlsx', '.xls']
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")
# 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 = 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("File validation complete",
filename=file.filename,
is_valid=result.is_valid,
unique_products=result.unique_products,
tenant_id=tenant_id)
return response
except HTTPException:
raise
except Exception as e:
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"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)
async def create_inventory_from_suggestions(
request: InventoryApprovalRequest,
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 3: Create inventory items from approved suggestions
This endpoint:
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 for final import step
"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
if not request.suggestions:
raise HTTPException(status_code=400, detail="No suggestions provided")
# Create inventory items using new service
result = await onboarding_service.create_inventory_from_suggestions(
approved_suggestions=request.suggestions,
tenant_id=tenant_id,
user_id=UUID(current_user['user_id'])
)
response = InventoryCreationResponse(
created_items=result['created_items'],
failed_items=result['failed_items'],
total_approved=result['total_approved'],
success_rate=result['success_rate']
)
logger.info("Inventory creation complete",
created=len(result['created_items']),
failed=len(result['failed_items']),
tenant_id=tenant_id)
return response
except HTTPException:
raise
except Exception as e:
logger.error("Failed inventory creation",
error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Inventory creation failed: {str(e)}")
@router.post("/tenants/{tenant_id}/onboarding/import-sales", response_model=SalesImportResponse)
async def import_sales_with_inventory(
file: UploadFile = File(..., description="Sales data CSV/Excel file"),
inventory_mapping: str = Form(..., description="JSON mapping of product names to inventory IDs"),
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 4: Final sales data import using created inventory items
This endpoint:
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
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Validate file
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
# Parse inventory mapping
import json
try:
mapping = json.loads(inventory_mapping)
# 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 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")
# 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_data=file_data,
file_format=file_format,
inventory_mapping=inventory_mapping_dict,
tenant_id=tenant_id,
filename=file.filename
)
response = SalesImportResponse(
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.import_details.records_created,
failed=result.import_details.records_failed,
filename=file.filename,
tenant_id=tenant_id)
return response
except HTTPException:
raise
except Exception as e:
logger.error("Failed sales import",
error=str(e), filename=file.filename if file else None, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Sales import failed: {str(e)}")
@router.get("/tenants/{tenant_id}/onboarding/business-model-guide")
async def get_business_model_guide(
model: str,
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)
):
"""
Get setup recommendations based on detected business model
"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
guides = {
'production': {
'title': 'Production Bakery Setup',
'description': 'Your bakery produces items from raw ingredients',
'next_steps': [
'Set up supplier relationships for ingredients',
'Configure recipe management and costing',
'Enable production planning and scheduling',
'Set up ingredient inventory alerts and reorder points'
],
'recommended_features': [
'Recipe & Production Management',
'Supplier & Procurement',
'Ingredient Inventory Tracking',
'Production Cost Analysis'
],
'sample_workflows': [
'Create recipes with ingredient costs',
'Plan daily production based on sales forecasts',
'Track ingredient usage and waste',
'Generate supplier purchase orders'
]
},
'retail': {
'title': 'Retail Bakery Setup',
'description': 'Your bakery sells finished products from central bakers',
'next_steps': [
'Configure central baker relationships',
'Set up delivery schedules and tracking',
'Enable finished product freshness monitoring',
'Focus on sales forecasting and ordering'
],
'recommended_features': [
'Central Baker Management',
'Delivery Schedule Tracking',
'Freshness Monitoring',
'Sales Forecasting'
],
'sample_workflows': [
'Set up central baker delivery schedules',
'Track product freshness and expiration',
'Forecast demand and place orders',
'Monitor sales performance by product'
]
},
'hybrid': {
'title': 'Hybrid Bakery Setup',
'description': 'Your bakery both produces items and sells finished products',
'next_steps': [
'Configure both production and retail features',
'Set up flexible inventory categories',
'Enable comprehensive analytics',
'Plan workflows for both business models'
],
'recommended_features': [
'Full Inventory Management',
'Recipe & Production Management',
'Central Baker Management',
'Advanced Analytics'
],
'sample_workflows': [
'Manage both ingredients and finished products',
'Balance production vs purchasing decisions',
'Track costs across both models',
'Optimize inventory mix based on profitability'
]
}
}
if model not in guides:
raise HTTPException(status_code=400, detail="Invalid business model")
return guides[model]
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get business model guide",
error=str(e), model=model, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get guide: {str(e)}")