Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View 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)}")

View File

@@ -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)}")

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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):

View File

@@ -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"]

View File

@@ -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

View File

@@ -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(

View File

@@ -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"
]

View File

@@ -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

View File

@@ -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"]

View 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()

View 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()

View File

@@ -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)

View File

@@ -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 []