499 lines
20 KiB
Python
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)}") |