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)}")
|
||||
@@ -47,6 +47,12 @@ class Settings(BaseServiceSettings):
|
||||
# Sales-specific cache TTL (5 minutes)
|
||||
SALES_CACHE_TTL: int = 300
|
||||
PRODUCT_CACHE_TTL: int = 600 # 10 minutes
|
||||
|
||||
# Inter-service communication
|
||||
INVENTORY_SERVICE_URL: str = Field(
|
||||
default="http://inventory-service:8000",
|
||||
env="INVENTORY_SERVICE_URL"
|
||||
)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
|
||||
@@ -104,7 +104,9 @@ app.add_middleware(
|
||||
# Include routers - import router BEFORE sales router to avoid conflicts
|
||||
from app.api.sales import router as sales_router
|
||||
from app.api.import_data import router as import_router
|
||||
from app.api.onboarding import router as onboarding_router
|
||||
app.include_router(import_router, prefix="/api/v1", tags=["import"])
|
||||
app.include_router(onboarding_router, prefix="/api/v1", tags=["onboarding"])
|
||||
app.include_router(sales_router, prefix="/api/v1", tags=["sales"])
|
||||
|
||||
# Health check endpoint
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# services/sales/app/models/__init__.py
|
||||
|
||||
from .sales import SalesData, Product, SalesImportJob
|
||||
from .sales import SalesData, SalesImportJob
|
||||
|
||||
__all__ = ["SalesData", "Product", "SalesImportJob"]
|
||||
__all__ = ["SalesData", "SalesImportJob"]
|
||||
@@ -4,7 +4,7 @@ Sales data models for Sales Service
|
||||
Enhanced with additional fields and relationships
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric
|
||||
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
@@ -22,10 +22,8 @@ class SalesData(Base):
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
# Product information
|
||||
product_name = Column(String(255), nullable=False, index=True)
|
||||
product_category = Column(String(100), nullable=True, index=True)
|
||||
product_sku = Column(String(100), nullable=True, index=True)
|
||||
# Product reference to inventory service (REQUIRED)
|
||||
inventory_product_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to inventory.ingredients.id
|
||||
|
||||
# Sales data
|
||||
quantity_sold = Column(Integer, nullable=False)
|
||||
@@ -60,18 +58,17 @@ class SalesData(Base):
|
||||
__table_args__ = (
|
||||
# Core query patterns
|
||||
Index('idx_sales_tenant_date', 'tenant_id', 'date'),
|
||||
Index('idx_sales_tenant_product', 'tenant_id', 'product_name'),
|
||||
Index('idx_sales_tenant_location', 'tenant_id', 'location_id'),
|
||||
Index('idx_sales_tenant_category', 'tenant_id', 'product_category'),
|
||||
|
||||
# Analytics queries
|
||||
Index('idx_sales_date_range', 'date', 'tenant_id'),
|
||||
Index('idx_sales_product_date', 'product_name', 'date', 'tenant_id'),
|
||||
Index('idx_sales_channel_date', 'sales_channel', 'date', 'tenant_id'),
|
||||
|
||||
# Data quality queries
|
||||
Index('idx_sales_source_validated', 'source', 'is_validated', 'tenant_id'),
|
||||
Index('idx_sales_sku_date', 'product_sku', 'date', 'tenant_id'),
|
||||
# Primary product reference index
|
||||
Index('idx_sales_inventory_product', 'inventory_product_id', 'tenant_id'),
|
||||
Index('idx_sales_product_date', 'inventory_product_id', 'date', 'tenant_id'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -80,9 +77,7 @@ class SalesData(Base):
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'date': self.date.isoformat() if self.date else None,
|
||||
'product_name': self.product_name,
|
||||
'product_category': self.product_category,
|
||||
'product_sku': self.product_sku,
|
||||
'inventory_product_id': str(self.inventory_product_id),
|
||||
'quantity_sold': self.quantity_sold,
|
||||
'unit_price': float(self.unit_price) if self.unit_price else None,
|
||||
'revenue': float(self.revenue) if self.revenue else None,
|
||||
@@ -110,70 +105,8 @@ class SalesData(Base):
|
||||
return None
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""Product catalog model - future expansion"""
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Product identification
|
||||
name = Column(String(255), nullable=False, index=True)
|
||||
sku = Column(String(100), nullable=True, index=True)
|
||||
category = Column(String(100), nullable=True, index=True)
|
||||
subcategory = Column(String(100), nullable=True)
|
||||
|
||||
# Product details
|
||||
description = Column(Text, nullable=True)
|
||||
unit_of_measure = Column(String(20), nullable=False, default="unit")
|
||||
weight = Column(Float, nullable=True) # in grams
|
||||
volume = Column(Float, nullable=True) # in ml
|
||||
|
||||
# Pricing
|
||||
base_price = Column(Numeric(10, 2), nullable=True)
|
||||
cost_price = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_seasonal = Column(Boolean, default=False)
|
||||
seasonal_start = Column(DateTime(timezone=True), nullable=True)
|
||||
seasonal_end = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_products_tenant_name', 'tenant_id', 'name', unique=True),
|
||||
Index('idx_products_tenant_sku', 'tenant_id', 'sku'),
|
||||
Index('idx_products_category', 'tenant_id', 'category', 'is_active'),
|
||||
Index('idx_products_seasonal', 'is_seasonal', 'seasonal_start', 'seasonal_end'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'name': self.name,
|
||||
'sku': self.sku,
|
||||
'category': self.category,
|
||||
'subcategory': self.subcategory,
|
||||
'description': self.description,
|
||||
'unit_of_measure': self.unit_of_measure,
|
||||
'weight': self.weight,
|
||||
'volume': self.volume,
|
||||
'base_price': float(self.base_price) if self.base_price else None,
|
||||
'cost_price': float(self.cost_price) if self.cost_price else None,
|
||||
'is_active': self.is_active,
|
||||
'is_seasonal': self.is_seasonal,
|
||||
'seasonal_start': self.seasonal_start.isoformat() if self.seasonal_start else None,
|
||||
'seasonal_end': self.seasonal_end.isoformat() if self.seasonal_end else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
# Product model removed - using inventory service as single source of truth
|
||||
# Product data is now referenced via inventory_product_id in SalesData model
|
||||
|
||||
|
||||
class SalesImportJob(Base):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# services/sales/app/repositories/__init__.py
|
||||
|
||||
from .sales_repository import SalesRepository
|
||||
from .product_repository import ProductRepository
|
||||
|
||||
__all__ = ["SalesRepository", "ProductRepository"]
|
||||
__all__ = ["SalesRepository"]
|
||||
@@ -1,193 +0,0 @@
|
||||
# services/sales/app/repositories/product_repository.py
|
||||
"""
|
||||
Product Repository using Repository Pattern
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select, and_, or_, desc, asc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from app.models.sales import Product
|
||||
from app.schemas.sales import ProductCreate, ProductUpdate
|
||||
from shared.database.repository import BaseRepository
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ProductRepository(BaseRepository[Product, ProductCreate, ProductUpdate]):
|
||||
"""Repository for product operations"""
|
||||
|
||||
def __init__(self, db_session: AsyncSession):
|
||||
super().__init__(Product, db_session)
|
||||
|
||||
async def create_product(self, product_data: ProductCreate, tenant_id: UUID) -> Product:
|
||||
"""Create a new product"""
|
||||
try:
|
||||
# Prepare data
|
||||
create_data = product_data.model_dump()
|
||||
create_data['tenant_id'] = tenant_id
|
||||
|
||||
# Create product
|
||||
product = await self.create(create_data)
|
||||
logger.info(
|
||||
"Created product",
|
||||
product_id=product.id,
|
||||
name=product.name,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return product
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create product", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_by_tenant(self, tenant_id: UUID, include_inactive: bool = False) -> List[Product]:
|
||||
"""Get all products for a tenant"""
|
||||
try:
|
||||
stmt = select(Product).where(Product.tenant_id == tenant_id)
|
||||
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(Product.is_active == True)
|
||||
|
||||
stmt = stmt.order_by(Product.category, Product.name)
|
||||
|
||||
result = await self.db_session.execute(stmt)
|
||||
products = result.scalars().all()
|
||||
|
||||
logger.info(
|
||||
"Retrieved products",
|
||||
count=len(products),
|
||||
tenant_id=tenant_id,
|
||||
include_inactive=include_inactive
|
||||
)
|
||||
return list(products)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get products", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_by_category(self, tenant_id: UUID, category: str) -> List[Product]:
|
||||
"""Get products by category"""
|
||||
try:
|
||||
stmt = select(Product).where(
|
||||
and_(
|
||||
Product.tenant_id == tenant_id,
|
||||
Product.category == category,
|
||||
Product.is_active == True
|
||||
)
|
||||
).order_by(Product.name)
|
||||
|
||||
result = await self.db_session.execute(stmt)
|
||||
products = result.scalars().all()
|
||||
|
||||
return list(products)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get products by category", error=str(e), tenant_id=tenant_id, category=category)
|
||||
raise
|
||||
|
||||
async def get_by_name(self, tenant_id: UUID, name: str) -> Optional[Product]:
|
||||
"""Get product by name"""
|
||||
try:
|
||||
stmt = select(Product).where(
|
||||
and_(
|
||||
Product.tenant_id == tenant_id,
|
||||
Product.name == name
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(stmt)
|
||||
product = result.scalar_one_or_none()
|
||||
|
||||
return product
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get product by name", error=str(e), tenant_id=tenant_id, name=name)
|
||||
raise
|
||||
|
||||
async def get_by_sku(self, tenant_id: UUID, sku: str) -> Optional[Product]:
|
||||
"""Get product by SKU"""
|
||||
try:
|
||||
stmt = select(Product).where(
|
||||
and_(
|
||||
Product.tenant_id == tenant_id,
|
||||
Product.sku == sku
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(stmt)
|
||||
product = result.scalar_one_or_none()
|
||||
|
||||
return product
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get product by SKU", error=str(e), tenant_id=tenant_id, sku=sku)
|
||||
raise
|
||||
|
||||
async def search_products(self, tenant_id: UUID, query: str, limit: int = 50) -> List[Product]:
|
||||
"""Search products by name or SKU"""
|
||||
try:
|
||||
stmt = select(Product).where(
|
||||
and_(
|
||||
Product.tenant_id == tenant_id,
|
||||
Product.is_active == True,
|
||||
or_(
|
||||
Product.name.ilike(f"%{query}%"),
|
||||
Product.sku.ilike(f"%{query}%"),
|
||||
Product.description.ilike(f"%{query}%")
|
||||
)
|
||||
)
|
||||
).order_by(Product.name).limit(limit)
|
||||
|
||||
result = await self.db_session.execute(stmt)
|
||||
products = result.scalars().all()
|
||||
|
||||
return list(products)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to search products", error=str(e), tenant_id=tenant_id, query=query)
|
||||
raise
|
||||
|
||||
async def get_categories(self, tenant_id: UUID) -> List[str]:
|
||||
"""Get distinct product categories for a tenant"""
|
||||
try:
|
||||
stmt = select(Product.category).where(
|
||||
and_(
|
||||
Product.tenant_id == tenant_id,
|
||||
Product.is_active == True,
|
||||
Product.category.is_not(None)
|
||||
)
|
||||
).distinct()
|
||||
|
||||
result = await self.db_session.execute(stmt)
|
||||
categories = [row[0] for row in result if row[0]]
|
||||
|
||||
return sorted(categories)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get product categories", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def deactivate_product(self, product_id: UUID) -> Product:
|
||||
"""Deactivate a product"""
|
||||
try:
|
||||
product = await self.update(product_id, {'is_active': False})
|
||||
logger.info("Deactivated product", product_id=product_id)
|
||||
return product
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to deactivate product", error=str(e), product_id=product_id)
|
||||
raise
|
||||
|
||||
async def activate_product(self, product_id: UUID) -> Product:
|
||||
"""Activate a product"""
|
||||
try:
|
||||
product = await self.update(product_id, {'is_active': True})
|
||||
logger.info("Activated product", product_id=product_id)
|
||||
return product
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to activate product", error=str(e), product_id=product_id)
|
||||
raise
|
||||
@@ -108,19 +108,19 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
logger.error("Failed to get sales records", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_by_product(
|
||||
async def get_by_inventory_product(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
product_name: str,
|
||||
inventory_product_id: UUID,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> List[SalesData]:
|
||||
"""Get sales records for a specific product"""
|
||||
"""Get sales records for a specific inventory product"""
|
||||
try:
|
||||
stmt = select(SalesData).where(
|
||||
and_(
|
||||
SalesData.tenant_id == tenant_id,
|
||||
SalesData.product_name == product_name
|
||||
SalesData.inventory_product_id == inventory_product_id
|
||||
)
|
||||
)
|
||||
|
||||
@@ -137,7 +137,7 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
return list(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
|
||||
|
||||
async def get_analytics(
|
||||
|
||||
@@ -5,9 +5,6 @@ from .sales import (
|
||||
SalesDataUpdate,
|
||||
SalesDataResponse,
|
||||
SalesDataQuery,
|
||||
ProductCreate,
|
||||
ProductUpdate,
|
||||
ProductResponse,
|
||||
SalesAnalytics,
|
||||
ProductSalesAnalytics
|
||||
)
|
||||
@@ -17,9 +14,6 @@ __all__ = [
|
||||
"SalesDataUpdate",
|
||||
"SalesDataResponse",
|
||||
"SalesDataQuery",
|
||||
"ProductCreate",
|
||||
"ProductUpdate",
|
||||
"ProductResponse",
|
||||
"SalesAnalytics",
|
||||
"ProductSalesAnalytics"
|
||||
]
|
||||
@@ -12,9 +12,8 @@ from decimal import Decimal
|
||||
|
||||
class SalesDataBase(BaseModel):
|
||||
"""Base sales data schema"""
|
||||
product_name: str = Field(..., min_length=1, max_length=255, description="Product name")
|
||||
product_category: Optional[str] = Field(None, max_length=100, description="Product category")
|
||||
product_sku: Optional[str] = Field(None, max_length=100, description="Product SKU")
|
||||
# Product reference - REQUIRED reference to inventory service
|
||||
inventory_product_id: UUID = Field(..., description="Reference to inventory service product")
|
||||
|
||||
quantity_sold: int = Field(..., gt=0, description="Quantity sold")
|
||||
unit_price: Optional[Decimal] = Field(None, ge=0, description="Unit price")
|
||||
@@ -119,61 +118,8 @@ class SalesDataQuery(BaseModel):
|
||||
return v.lower()
|
||||
|
||||
|
||||
# Product schemas
|
||||
class ProductBase(BaseModel):
|
||||
"""Base product schema"""
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Product name")
|
||||
sku: Optional[str] = Field(None, max_length=100, description="Stock Keeping Unit")
|
||||
category: Optional[str] = Field(None, max_length=100, description="Product category")
|
||||
subcategory: Optional[str] = Field(None, max_length=100, description="Product subcategory")
|
||||
|
||||
description: Optional[str] = Field(None, description="Product description")
|
||||
unit_of_measure: str = Field("unit", description="Unit of measure")
|
||||
weight: Optional[float] = Field(None, gt=0, description="Weight in grams")
|
||||
volume: Optional[float] = Field(None, gt=0, description="Volume in ml")
|
||||
|
||||
base_price: Optional[Decimal] = Field(None, ge=0, description="Base selling price")
|
||||
cost_price: Optional[Decimal] = Field(None, ge=0, description="Cost price")
|
||||
|
||||
is_seasonal: bool = Field(False, description="Seasonal product flag")
|
||||
seasonal_start: Optional[datetime] = Field(None, description="Season start date")
|
||||
seasonal_end: Optional[datetime] = Field(None, description="Season end date")
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
"""Schema for creating products"""
|
||||
tenant_id: Optional[UUID] = Field(None, description="Tenant ID (set automatically)")
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
"""Schema for updating products"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
sku: Optional[str] = Field(None, max_length=100)
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
subcategory: Optional[str] = Field(None, max_length=100)
|
||||
description: Optional[str] = None
|
||||
unit_of_measure: Optional[str] = None
|
||||
weight: Optional[float] = Field(None, gt=0)
|
||||
volume: Optional[float] = Field(None, gt=0)
|
||||
base_price: Optional[Decimal] = Field(None, ge=0)
|
||||
cost_price: Optional[Decimal] = Field(None, ge=0)
|
||||
is_active: Optional[bool] = None
|
||||
is_seasonal: Optional[bool] = None
|
||||
seasonal_start: Optional[datetime] = None
|
||||
seasonal_end: Optional[datetime] = None
|
||||
|
||||
|
||||
class ProductResponse(ProductBase):
|
||||
"""Schema for product responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
is_active: bool = True
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# Product schemas removed - using inventory service as single source of truth
|
||||
# Product data is accessed via inventory service client
|
||||
|
||||
|
||||
# Analytics schemas
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# services/sales/app/services/__init__.py
|
||||
|
||||
from .sales_service import SalesService
|
||||
from .product_service import ProductService
|
||||
from .data_import_service import DataImportService
|
||||
from .messaging import SalesEventPublisher, sales_publisher
|
||||
|
||||
__all__ = ["SalesService", "ProductService", "DataImportService", "SalesEventPublisher", "sales_publisher"]
|
||||
__all__ = ["SalesService", "DataImportService", "SalesEventPublisher", "sales_publisher"]
|
||||
222
services/sales/app/services/inventory_client.py
Normal file
222
services/sales/app/services/inventory_client.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# services/sales/app/services/inventory_client.py
|
||||
"""
|
||||
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 app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
class InventoryServiceClient:
|
||||
"""Client for communicating with the inventory service"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.INVENTORY_SERVICE_URL
|
||||
self.timeout = 30.0
|
||||
|
||||
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 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
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
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",
|
||||
error=str(e), sku=sku, tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def search_products(self, search_term: str, tenant_id: UUID,
|
||||
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 []
|
||||
except Exception as e:
|
||||
logger.error("Error searching products in inventory service",
|
||||
error=str(e), search_term=search_term, tenant_id=tenant_id)
|
||||
return []
|
||||
|
||||
async def get_products_by_category(self, category: str, tenant_id: UUID,
|
||||
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 []
|
||||
except Exception as e:
|
||||
logger.error("Error fetching products by category from inventory service",
|
||||
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:
|
||||
"""Get inventory service client instance"""
|
||||
return InventoryServiceClient()
|
||||
446
services/sales/app/services/onboarding_import_service.py
Normal file
446
services/sales/app/services/onboarding_import_service.py
Normal file
@@ -0,0 +1,446 @@
|
||||
# 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()
|
||||
@@ -1,171 +0,0 @@
|
||||
# services/sales/app/services/product_service.py
|
||||
"""
|
||||
Product Service - Business Logic Layer
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
|
||||
from app.models.sales import Product
|
||||
from app.repositories.product_repository import ProductRepository
|
||||
from app.schemas.sales import ProductCreate, ProductUpdate
|
||||
from app.core.database import get_db_transaction
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ProductService:
|
||||
"""Service layer for product operations"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def create_product(
|
||||
self,
|
||||
product_data: ProductCreate,
|
||||
tenant_id: UUID,
|
||||
user_id: Optional[UUID] = None
|
||||
) -> Product:
|
||||
"""Create a new product with business validation"""
|
||||
try:
|
||||
# Business validation
|
||||
await self._validate_product_data(product_data, tenant_id)
|
||||
|
||||
async with get_db_transaction() as db:
|
||||
repository = ProductRepository(db)
|
||||
product = await repository.create_product(product_data, tenant_id)
|
||||
|
||||
logger.info("Created product", product_id=product.id, tenant_id=tenant_id)
|
||||
return product
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create product", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def update_product(
|
||||
self,
|
||||
product_id: UUID,
|
||||
update_data: ProductUpdate,
|
||||
tenant_id: UUID
|
||||
) -> Product:
|
||||
"""Update a product"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
repository = ProductRepository(db)
|
||||
|
||||
# Verify product belongs to tenant
|
||||
existing_product = await repository.get_by_id(product_id)
|
||||
if not existing_product or existing_product.tenant_id != tenant_id:
|
||||
raise ValueError(f"Product {product_id} not found for tenant {tenant_id}")
|
||||
|
||||
# Update the product
|
||||
updated_product = await repository.update(product_id, update_data.model_dump(exclude_unset=True))
|
||||
|
||||
logger.info("Updated product", product_id=product_id, tenant_id=tenant_id)
|
||||
return updated_product
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update product", error=str(e), product_id=product_id, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_products(self, tenant_id: UUID) -> List[Product]:
|
||||
"""Get all products for a tenant"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
repository = ProductRepository(db)
|
||||
products = await repository.get_by_tenant(tenant_id)
|
||||
|
||||
logger.info("Retrieved products", count=len(products), tenant_id=tenant_id)
|
||||
return products
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get products", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_product(self, product_id: UUID, tenant_id: UUID) -> Optional[Product]:
|
||||
"""Get a specific product"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
repository = ProductRepository(db)
|
||||
product = await repository.get_by_id(product_id)
|
||||
|
||||
# Verify product belongs to tenant
|
||||
if product and product.tenant_id != tenant_id:
|
||||
return None
|
||||
|
||||
return product
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get product", error=str(e), product_id=product_id, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def delete_product(self, product_id: UUID, tenant_id: UUID) -> bool:
|
||||
"""Delete a product"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
repository = ProductRepository(db)
|
||||
|
||||
# Verify product belongs to tenant
|
||||
existing_product = await repository.get_by_id(product_id)
|
||||
if not existing_product or existing_product.tenant_id != tenant_id:
|
||||
raise ValueError(f"Product {product_id} not found for tenant {tenant_id}")
|
||||
|
||||
success = await repository.delete(product_id)
|
||||
|
||||
if success:
|
||||
logger.info("Deleted product", product_id=product_id, tenant_id=tenant_id)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete product", error=str(e), product_id=product_id, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_products_by_category(self, tenant_id: UUID, category: str) -> List[Product]:
|
||||
"""Get products by category"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
repository = ProductRepository(db)
|
||||
products = await repository.get_by_category(tenant_id, category)
|
||||
|
||||
logger.info("Retrieved products by category", count=len(products), category=category, tenant_id=tenant_id)
|
||||
return products
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get products by category", error=str(e), category=category, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def search_products(self, tenant_id: UUID, search_term: str) -> List[Product]:
|
||||
"""Search products by name or SKU"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
repository = ProductRepository(db)
|
||||
products = await repository.search_products(tenant_id, search_term)
|
||||
|
||||
logger.info("Searched products", count=len(products), search_term=search_term, tenant_id=tenant_id)
|
||||
return products
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to search products", error=str(e), search_term=search_term, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def _validate_product_data(self, product_data: ProductCreate, tenant_id: UUID):
|
||||
"""Validate product data according to business rules"""
|
||||
# Check if product with same SKU already exists
|
||||
if product_data.sku:
|
||||
async with get_db_transaction() as db:
|
||||
repository = ProductRepository(db)
|
||||
existing_product = await repository.get_by_sku(tenant_id, product_data.sku)
|
||||
if existing_product:
|
||||
raise ValueError(f"Product with SKU {product_data.sku} already exists for tenant {tenant_id}")
|
||||
|
||||
# Validate seasonal dates
|
||||
if product_data.is_seasonal:
|
||||
if not product_data.seasonal_start or not product_data.seasonal_end:
|
||||
raise ValueError("Seasonal products must have start and end dates")
|
||||
if product_data.seasonal_start >= product_data.seasonal_end:
|
||||
raise ValueError("Seasonal start date must be before end date")
|
||||
|
||||
logger.info("Product data validation passed", tenant_id=tenant_id)
|
||||
@@ -12,6 +12,7 @@ from app.models.sales import SalesData
|
||||
from app.repositories.sales_repository import SalesRepository
|
||||
from app.schemas.sales import SalesDataCreate, SalesDataUpdate, SalesDataQuery, SalesAnalytics
|
||||
from app.core.database import get_db_transaction
|
||||
from app.services.inventory_client import InventoryServiceClient
|
||||
from shared.database.exceptions import DatabaseError
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -21,7 +22,7 @@ class SalesService:
|
||||
"""Service layer for sales operations"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self.inventory_client = InventoryServiceClient()
|
||||
|
||||
async def create_sales_record(
|
||||
self,
|
||||
@@ -31,6 +32,20 @@ class SalesService:
|
||||
) -> SalesData:
|
||||
"""Create a new sales record with business validation"""
|
||||
try:
|
||||
# Sync product data with inventory service if inventory_product_id is provided
|
||||
if sales_data.inventory_product_id:
|
||||
product_cache = await self.inventory_client.sync_product_cache(
|
||||
sales_data.inventory_product_id, tenant_id
|
||||
)
|
||||
if product_cache:
|
||||
# Update cached product fields from inventory
|
||||
sales_data_dict = sales_data.model_dump()
|
||||
sales_data_dict.update(product_cache)
|
||||
sales_data = SalesDataCreate(**sales_data_dict)
|
||||
else:
|
||||
logger.warning("Could not sync product from inventory",
|
||||
product_id=sales_data.inventory_product_id, tenant_id=tenant_id)
|
||||
|
||||
# Business validation
|
||||
await self._validate_sales_data(sales_data, tenant_id)
|
||||
|
||||
@@ -139,26 +154,26 @@ class SalesService:
|
||||
async def get_product_sales(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
product_name: str,
|
||||
inventory_product_id: UUID,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> List[SalesData]:
|
||||
"""Get sales records for a specific product"""
|
||||
"""Get sales records for a specific product by inventory ID"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
repository = SalesRepository(db)
|
||||
records = await repository.get_by_product(tenant_id, product_name, start_date, end_date)
|
||||
records = await repository.get_by_inventory_product(tenant_id, inventory_product_id, start_date, end_date)
|
||||
|
||||
logger.info(
|
||||
"Retrieved product sales",
|
||||
count=len(records),
|
||||
product=product_name,
|
||||
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
|
||||
|
||||
async def get_sales_analytics(
|
||||
@@ -181,13 +196,23 @@ class SalesService:
|
||||
raise
|
||||
|
||||
async def get_product_categories(self, tenant_id: UUID) -> List[str]:
|
||||
"""Get distinct product categories"""
|
||||
"""Get distinct product categories from inventory service"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
repository = SalesRepository(db)
|
||||
categories = await repository.get_product_categories(tenant_id)
|
||||
|
||||
return categories
|
||||
# Get all unique categories from inventory service products
|
||||
# This is more accurate than cached categories in sales data
|
||||
ingredient_products = await self.inventory_client.search_products("", tenant_id, "ingredient")
|
||||
finished_products = await self.inventory_client.search_products("", tenant_id, "finished_product")
|
||||
|
||||
categories = set()
|
||||
for product in ingredient_products:
|
||||
if product.get("ingredient_category"):
|
||||
categories.add(product["ingredient_category"])
|
||||
|
||||
for product in finished_products:
|
||||
if product.get("product_category"):
|
||||
categories.add(product["product_category"])
|
||||
|
||||
return sorted(list(categories))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get product categories", error=str(e), tenant_id=tenant_id)
|
||||
@@ -279,4 +304,43 @@ class SalesService:
|
||||
logger.error("Failed to get products list",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id)
|
||||
raise DatabaseError(f"Failed to get products list: {str(e)}")
|
||||
raise DatabaseError(f"Failed to get products list: {str(e)}")
|
||||
|
||||
# New inventory integration methods
|
||||
async def search_inventory_products(self, search_term: str, tenant_id: UUID,
|
||||
product_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Search products in inventory service"""
|
||||
try:
|
||||
products = await self.inventory_client.search_products(search_term, tenant_id, product_type)
|
||||
logger.info("Searched inventory products", search_term=search_term,
|
||||
count=len(products), tenant_id=tenant_id)
|
||||
return products
|
||||
except Exception as e:
|
||||
logger.error("Failed to search inventory products",
|
||||
error=str(e), search_term=search_term, tenant_id=tenant_id)
|
||||
return []
|
||||
|
||||
async def get_inventory_product(self, product_id: UUID, tenant_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get product details from inventory service"""
|
||||
try:
|
||||
product = await self.inventory_client.get_product_by_id(product_id, tenant_id)
|
||||
if product:
|
||||
logger.info("Retrieved inventory product", product_id=product_id, tenant_id=tenant_id)
|
||||
return product
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory product",
|
||||
error=str(e), product_id=product_id, tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_inventory_products_by_category(self, category: str, tenant_id: UUID,
|
||||
product_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get products by category from inventory service"""
|
||||
try:
|
||||
products = await self.inventory_client.get_products_by_category(category, tenant_id, product_type)
|
||||
logger.info("Retrieved inventory products by category", category=category,
|
||||
count=len(products), tenant_id=tenant_id)
|
||||
return products
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory products by category",
|
||||
error=str(e), category=category, tenant_id=tenant_id)
|
||||
return []
|
||||
Reference in New Issue
Block a user