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

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