Create new services: inventory, recipes, suppliers
This commit is contained in:
368
services/sales/app/api/onboarding.py
Normal file
368
services/sales/app/api/onboarding.py
Normal file
@@ -0,0 +1,368 @@
|
||||
# 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.onboarding_import_service import (
|
||||
OnboardingImportService,
|
||||
OnboardingImportResult,
|
||||
InventoryCreationRequest,
|
||||
get_onboarding_import_service
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
||||
|
||||
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):
|
||||
"""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/analyze", response_model=OnboardingAnalysisResponse)
|
||||
async def analyze_onboarding_data(
|
||||
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)
|
||||
):
|
||||
"""
|
||||
Step 1: Analyze uploaded sales data and suggest inventory items
|
||||
|
||||
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
|
||||
"""
|
||||
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}")
|
||||
|
||||
# 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'])
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
logger.info("Onboarding analysis complete",
|
||||
filename=file.filename,
|
||||
products_found=result.total_products_found,
|
||||
business_model=result.business_model_analysis.get('model'),
|
||||
tenant_id=tenant_id)
|
||||
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed onboarding analysis",
|
||||
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)}")
|
||||
|
||||
|
||||
@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: OnboardingImportService = Depends(get_onboarding_import_service)
|
||||
):
|
||||
"""
|
||||
Step 2: Create inventory items from approved suggestions
|
||||
|
||||
This endpoint:
|
||||
1. Takes user-approved inventory suggestions
|
||||
2. Applies any user modifications
|
||||
3. Creates inventory items via inventory service
|
||||
4. Returns creation results
|
||||
"""
|
||||
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")
|
||||
|
||||
# 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
|
||||
result = await onboarding_service.create_inventory_from_suggestions(
|
||||
suggestions_approval=approval_requests,
|
||||
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: OnboardingImportService = Depends(get_onboarding_import_service)
|
||||
):
|
||||
"""
|
||||
Step 3: Import sales data 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
|
||||
"""
|
||||
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 string UUIDs to UUID objects
|
||||
inventory_mapping_uuids = {
|
||||
product_name: UUID(inventory_id)
|
||||
for product_name, inventory_id in mapping.items()
|
||||
}
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid inventory mapping format: {str(e)}")
|
||||
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
if not file_content:
|
||||
raise HTTPException(status_code=400, detail="File is empty")
|
||||
|
||||
# Import sales data
|
||||
result = await onboarding_service.import_sales_data_with_inventory(
|
||||
file_content=file_content,
|
||||
filename=file.filename,
|
||||
tenant_id=tenant_id,
|
||||
user_id=UUID(current_user['user_id']),
|
||||
inventory_mapping=inventory_mapping_uuids
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
logger.info("Sales import complete",
|
||||
successful=result.successful_imports,
|
||||
failed=result.failed_imports,
|
||||
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)}")
|
||||
@@ -165,10 +165,10 @@ async def get_sales_analytics(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get sales analytics: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/products/{product_name}/sales", response_model=List[SalesDataResponse])
|
||||
@router.get("/tenants/{tenant_id}/inventory-products/{inventory_product_id}/sales", response_model=List[SalesDataResponse])
|
||||
async def get_product_sales(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
product_name: str = Path(..., description="Product name"),
|
||||
inventory_product_id: UUID = Path(..., description="Inventory product ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Start date filter"),
|
||||
end_date: Optional[datetime] = Query(None, description="End date filter"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
@@ -180,13 +180,13 @@ async def get_product_sales(
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
records = await sales_service.get_product_sales(tenant_id, product_name, start_date, end_date)
|
||||
records = await sales_service.get_product_sales(tenant_id, inventory_product_id, start_date, end_date)
|
||||
|
||||
logger.info("Retrieved product sales", count=len(records), product=product_name, tenant_id=tenant_id)
|
||||
logger.info("Retrieved product sales", count=len(records), inventory_product_id=inventory_product_id, tenant_id=tenant_id)
|
||||
return records
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get product sales", error=str(e), tenant_id=tenant_id, product=product_name)
|
||||
logger.error("Failed to get product sales", error=str(e), tenant_id=tenant_id, inventory_product_id=inventory_product_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get product sales: {str(e)}")
|
||||
|
||||
|
||||
@@ -322,4 +322,81 @@ async def validate_sales_record(
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error("Failed to validate sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to validate sales record: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to validate sales record: {str(e)}")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# INVENTORY INTEGRATION ENDPOINTS
|
||||
# ================================================================
|
||||
|
||||
@router.get("/tenants/{tenant_id}/inventory/products/search")
|
||||
async def search_inventory_products(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
search: str = Query(..., description="Search term"),
|
||||
product_type: Optional[str] = Query(None, description="Product type filter"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
sales_service: SalesService = Depends(get_sales_service)
|
||||
):
|
||||
"""Search products in inventory service"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
products = await sales_service.search_inventory_products(search, tenant_id, product_type)
|
||||
|
||||
return {"items": products, "count": len(products)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to search inventory products", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to search inventory products: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/inventory/products/{product_id}")
|
||||
async def get_inventory_product(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
product_id: UUID = Path(..., description="Product ID from inventory service"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
sales_service: SalesService = Depends(get_sales_service)
|
||||
):
|
||||
"""Get product details from inventory service"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
product = await sales_service.get_inventory_product(product_id, tenant_id)
|
||||
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found in inventory")
|
||||
|
||||
return product
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory product", error=str(e), product_id=product_id, tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get inventory product: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/inventory/products/category/{category}")
|
||||
async def get_inventory_products_by_category(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
category: str = Path(..., description="Product category"),
|
||||
product_type: Optional[str] = Query(None, description="Product type filter"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
sales_service: SalesService = Depends(get_sales_service)
|
||||
):
|
||||
"""Get products by category from inventory service"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
products = await sales_service.get_inventory_products_by_category(category, tenant_id, product_type)
|
||||
|
||||
return {"items": products, "count": len(products)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory products by category", error=str(e), category=category, tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get inventory products by category: {str(e)}")
|
||||
Reference in New Issue
Block a user