282 lines
11 KiB
Python
282 lines
11 KiB
Python
|
|
# services/sales/app/services/sales_service.py
|
||
|
|
"""
|
||
|
|
Sales 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 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 shared.database.exceptions import DatabaseError
|
||
|
|
|
||
|
|
logger = structlog.get_logger()
|
||
|
|
|
||
|
|
|
||
|
|
class SalesService:
|
||
|
|
"""Service layer for sales operations"""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
async def create_sales_record(
|
||
|
|
self,
|
||
|
|
sales_data: SalesDataCreate,
|
||
|
|
tenant_id: UUID,
|
||
|
|
user_id: Optional[UUID] = None
|
||
|
|
) -> SalesData:
|
||
|
|
"""Create a new sales record with business validation"""
|
||
|
|
try:
|
||
|
|
# Business validation
|
||
|
|
await self._validate_sales_data(sales_data, tenant_id)
|
||
|
|
|
||
|
|
# Set user who created the record
|
||
|
|
if user_id:
|
||
|
|
sales_data_dict = sales_data.model_dump()
|
||
|
|
sales_data_dict['created_by'] = user_id
|
||
|
|
sales_data = SalesDataCreate(**sales_data_dict)
|
||
|
|
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
record = await repository.create_sales_record(sales_data, tenant_id)
|
||
|
|
|
||
|
|
# Additional business logic (e.g., notifications, analytics updates)
|
||
|
|
await self._post_create_actions(record)
|
||
|
|
|
||
|
|
return record
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to create sales record in service", error=str(e), tenant_id=tenant_id)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def update_sales_record(
|
||
|
|
self,
|
||
|
|
record_id: UUID,
|
||
|
|
update_data: SalesDataUpdate,
|
||
|
|
tenant_id: UUID
|
||
|
|
) -> SalesData:
|
||
|
|
"""Update a sales record"""
|
||
|
|
try:
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
|
||
|
|
# Verify record belongs to tenant
|
||
|
|
existing_record = await repository.get_by_id(record_id)
|
||
|
|
if not existing_record or existing_record.tenant_id != tenant_id:
|
||
|
|
raise ValueError(f"Sales record {record_id} not found for tenant {tenant_id}")
|
||
|
|
|
||
|
|
# Update the record
|
||
|
|
updated_record = await repository.update(record_id, update_data.model_dump(exclude_unset=True))
|
||
|
|
|
||
|
|
logger.info("Updated sales record", record_id=record_id, tenant_id=tenant_id)
|
||
|
|
return updated_record
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to update sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def get_sales_records(
|
||
|
|
self,
|
||
|
|
tenant_id: UUID,
|
||
|
|
query_params: Optional[SalesDataQuery] = None
|
||
|
|
) -> List[SalesData]:
|
||
|
|
"""Get sales records for a tenant"""
|
||
|
|
try:
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
records = await repository.get_by_tenant(tenant_id, query_params)
|
||
|
|
|
||
|
|
logger.info("Retrieved sales records", count=len(records), tenant_id=tenant_id)
|
||
|
|
return records
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get sales records", error=str(e), tenant_id=tenant_id)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def get_sales_record(self, record_id: UUID, tenant_id: UUID) -> Optional[SalesData]:
|
||
|
|
"""Get a specific sales record"""
|
||
|
|
try:
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
record = await repository.get_by_id(record_id)
|
||
|
|
|
||
|
|
# Verify record belongs to tenant
|
||
|
|
if record and record.tenant_id != tenant_id:
|
||
|
|
return None
|
||
|
|
|
||
|
|
return record
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def delete_sales_record(self, record_id: UUID, tenant_id: UUID) -> bool:
|
||
|
|
"""Delete a sales record"""
|
||
|
|
try:
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
|
||
|
|
# Verify record belongs to tenant
|
||
|
|
existing_record = await repository.get_by_id(record_id)
|
||
|
|
if not existing_record or existing_record.tenant_id != tenant_id:
|
||
|
|
raise ValueError(f"Sales record {record_id} not found for tenant {tenant_id}")
|
||
|
|
|
||
|
|
success = await repository.delete(record_id)
|
||
|
|
|
||
|
|
if success:
|
||
|
|
logger.info("Deleted sales record", record_id=record_id, tenant_id=tenant_id)
|
||
|
|
|
||
|
|
return success
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to delete sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def get_product_sales(
|
||
|
|
self,
|
||
|
|
tenant_id: UUID,
|
||
|
|
product_name: str,
|
||
|
|
start_date: Optional[datetime] = None,
|
||
|
|
end_date: Optional[datetime] = None
|
||
|
|
) -> List[SalesData]:
|
||
|
|
"""Get sales records for a specific product"""
|
||
|
|
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)
|
||
|
|
|
||
|
|
logger.info(
|
||
|
|
"Retrieved product sales",
|
||
|
|
count=len(records),
|
||
|
|
product=product_name,
|
||
|
|
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)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def get_sales_analytics(
|
||
|
|
self,
|
||
|
|
tenant_id: UUID,
|
||
|
|
start_date: Optional[datetime] = None,
|
||
|
|
end_date: Optional[datetime] = None
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
"""Get sales analytics for a tenant"""
|
||
|
|
try:
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
analytics = await repository.get_analytics(tenant_id, start_date, end_date)
|
||
|
|
|
||
|
|
logger.info("Retrieved sales analytics", tenant_id=tenant_id)
|
||
|
|
return analytics
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get sales analytics", error=str(e), tenant_id=tenant_id)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def get_product_categories(self, tenant_id: UUID) -> List[str]:
|
||
|
|
"""Get distinct product categories"""
|
||
|
|
try:
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
categories = await repository.get_product_categories(tenant_id)
|
||
|
|
|
||
|
|
return categories
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get product categories", error=str(e), tenant_id=tenant_id)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def validate_sales_record(
|
||
|
|
self,
|
||
|
|
record_id: UUID,
|
||
|
|
tenant_id: UUID,
|
||
|
|
validation_notes: Optional[str] = None
|
||
|
|
) -> SalesData:
|
||
|
|
"""Validate a sales record"""
|
||
|
|
try:
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
|
||
|
|
# Verify record belongs to tenant
|
||
|
|
existing_record = await repository.get_by_id(record_id)
|
||
|
|
if not existing_record or existing_record.tenant_id != tenant_id:
|
||
|
|
raise ValueError(f"Sales record {record_id} not found for tenant {tenant_id}")
|
||
|
|
|
||
|
|
validated_record = await repository.validate_record(record_id, validation_notes)
|
||
|
|
|
||
|
|
logger.info("Validated sales record", record_id=record_id, tenant_id=tenant_id)
|
||
|
|
return validated_record
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to validate sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def _validate_sales_data(self, sales_data: SalesDataCreate, tenant_id: UUID):
|
||
|
|
"""Validate sales data according to business rules"""
|
||
|
|
# Example business validations
|
||
|
|
|
||
|
|
# Check if revenue matches quantity * unit_price (if unit_price provided)
|
||
|
|
if sales_data.unit_price and sales_data.quantity_sold:
|
||
|
|
expected_revenue = sales_data.unit_price * sales_data.quantity_sold
|
||
|
|
# Apply discount if any
|
||
|
|
if sales_data.discount_applied:
|
||
|
|
expected_revenue *= (1 - sales_data.discount_applied / 100)
|
||
|
|
|
||
|
|
# Allow for small rounding differences
|
||
|
|
if abs(float(sales_data.revenue) - float(expected_revenue)) > 0.01:
|
||
|
|
logger.warning(
|
||
|
|
"Revenue mismatch detected",
|
||
|
|
expected=float(expected_revenue),
|
||
|
|
actual=float(sales_data.revenue),
|
||
|
|
tenant_id=tenant_id
|
||
|
|
)
|
||
|
|
|
||
|
|
# Check date validity (not in future)
|
||
|
|
if sales_data.date > datetime.utcnow():
|
||
|
|
raise ValueError("Sales date cannot be in the future")
|
||
|
|
|
||
|
|
# Additional business rules can be added here
|
||
|
|
logger.info("Sales data validation passed", tenant_id=tenant_id)
|
||
|
|
|
||
|
|
async def _post_create_actions(self, record: SalesData):
|
||
|
|
"""Actions to perform after creating a sales record"""
|
||
|
|
try:
|
||
|
|
# Here you could:
|
||
|
|
# - Send notifications
|
||
|
|
# - Update analytics caches
|
||
|
|
# - Trigger ML model updates
|
||
|
|
# - Update inventory levels (future integration)
|
||
|
|
|
||
|
|
logger.info("Post-create actions completed", record_id=record.id)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
# Don't fail the main operation for auxiliary actions
|
||
|
|
logger.warning("Failed to execute post-create actions", error=str(e), record_id=record.id)
|
||
|
|
|
||
|
|
async def get_products_list(self, tenant_id: str) -> List[Dict[str, Any]]:
|
||
|
|
"""Get list of all products with sales data for tenant using repository pattern"""
|
||
|
|
try:
|
||
|
|
async with get_db_transaction() as db:
|
||
|
|
repository = SalesRepository(db)
|
||
|
|
|
||
|
|
# Use repository method for product statistics
|
||
|
|
products = await repository.get_product_statistics(tenant_id)
|
||
|
|
|
||
|
|
logger.debug("Products list retrieved successfully",
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
product_count=len(products))
|
||
|
|
|
||
|
|
return products
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to get products list",
|
||
|
|
error=str(e),
|
||
|
|
tenant_id=tenant_id)
|
||
|
|
raise DatabaseError(f"Failed to get products list: {str(e)}")
|