# app/modules/analytics/services/stats_service.py """ Statistics service for generating system analytics and metrics. This is the canonical location for the stats service. This module provides: - System-wide statistics (admin) - Store-specific statistics - Marketplace analytics - Performance metrics """ import logging from datetime import datetime, timedelta from typing import Any from sqlalchemy import func from sqlalchemy.orm import Session from app.modules.catalog.models import Product from app.modules.customers.models.customer import Customer from app.modules.inventory.models import Inventory from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct from app.modules.orders.models import Order from app.modules.tenancy.exceptions import ( AdminOperationException, StoreNotFoundException, ) from app.modules.tenancy.models import Store, User logger = logging.getLogger(__name__) class StatsService: """Service for statistics operations.""" # ======================================================================== # STORE-SPECIFIC STATISTICS # ======================================================================== def get_store_stats(self, db: Session, store_id: int) -> dict[str, Any]: """ Get statistics for a specific store. Args: db: Database session store_id: Store ID Returns: Dictionary with store statistics Raises: StoreNotFoundException: If store doesn't exist AdminOperationException: If database query fails """ # Verify store exists store = db.query(Store).filter(Store.id == store_id).first() if not store: raise StoreNotFoundException(str(store_id), identifier_type="id") try: # Catalog statistics total_catalog_products = ( db.query(Product) .filter(Product.store_id == store_id, Product.is_active == True) .count() ) featured_products = ( db.query(Product) .filter( Product.store_id == store_id, Product.is_featured == True, Product.is_active == True, ) .count() ) # Staging statistics # TODO: This is fragile - MarketplaceProduct uses store_name (string) not store_id # Should add store_id foreign key to MarketplaceProduct for robust querying # For now, matching by store name which could fail if names don't match exactly staging_products = ( db.query(MarketplaceProduct) .filter(MarketplaceProduct.store_name == store.name) .count() ) # Inventory statistics total_inventory = ( db.query(func.sum(Inventory.quantity)) .filter(Inventory.store_id == store_id) .scalar() or 0 ) reserved_inventory = ( db.query(func.sum(Inventory.reserved_quantity)) .filter(Inventory.store_id == store_id) .scalar() or 0 ) inventory_locations = ( db.query(func.count(func.distinct(Inventory.location))) .filter(Inventory.store_id == store_id) .scalar() or 0 ) # Import statistics total_imports = ( db.query(MarketplaceImportJob) .filter(MarketplaceImportJob.store_id == store_id) .count() ) successful_imports = ( db.query(MarketplaceImportJob) .filter( MarketplaceImportJob.store_id == store_id, MarketplaceImportJob.status == "completed", ) .count() ) # Orders total_orders = db.query(Order).filter(Order.store_id == store_id).count() # Customers total_customers = ( db.query(Customer).filter(Customer.store_id == store_id).count() ) # Return flat structure compatible with StoreDashboardStatsResponse schema # The endpoint will restructure this into nested format return { # Product stats "total_products": total_catalog_products, "active_products": total_catalog_products, "featured_products": featured_products, # Order stats (TODO: implement when Order model has status field) "total_orders": total_orders, "pending_orders": 0, # TODO: filter by status "completed_orders": 0, # TODO: filter by status # Customer stats "total_customers": total_customers, "active_customers": 0, # TODO: implement active customer logic # Revenue stats (TODO: implement when Order model has amount field) "total_revenue": 0, "revenue_this_month": 0, # Import stats "total_imports": total_imports, "successful_imports": successful_imports, "import_success_rate": ( (successful_imports / total_imports * 100) if total_imports > 0 else 0 ), # Staging stats "imported_products": staging_products, # Inventory stats "total_inventory_quantity": int(total_inventory), "reserved_inventory_quantity": int(reserved_inventory), "available_inventory_quantity": int( total_inventory - reserved_inventory ), "inventory_locations_count": inventory_locations, } except StoreNotFoundException: raise except Exception as e: logger.error( f"Failed to retrieve store statistics for store {store_id}: {str(e)}" ) raise AdminOperationException( operation="get_store_stats", reason=f"Database query failed: {str(e)}", target_type="store", target_id=str(store_id), ) def get_store_analytics( self, db: Session, store_id: int, period: str = "30d" ) -> dict[str, Any]: """ Get a specific store analytics for a time period. Args: db: Database session store_id: Store ID period: Time period (7d, 30d, 90d, 1y) Returns: Analytics data Raises: StoreNotFoundException: If store doesn't exist AdminOperationException: If database query fails """ # Verify store exists store = db.query(Store).filter(Store.id == store_id).first() if not store: raise StoreNotFoundException(str(store_id), identifier_type="id") try: # Parse period days = self._parse_period(period) start_date = datetime.utcnow() - timedelta(days=days) # Import activity recent_imports = ( db.query(MarketplaceImportJob) .filter( MarketplaceImportJob.store_id == store_id, MarketplaceImportJob.created_at >= start_date, ) .count() ) # Products added to catalog products_added = ( db.query(Product) .filter( Product.store_id == store_id, Product.created_at >= start_date ) .count() ) # Inventory changes inventory_entries = ( db.query(Inventory).filter(Inventory.store_id == store_id).count() ) return { "period": period, "start_date": start_date.isoformat(), "imports": { "count": recent_imports, }, "catalog": { "products_added": products_added, }, "inventory": { "total_locations": inventory_entries, }, } except StoreNotFoundException: raise except Exception as e: logger.error( f"Failed to retrieve store analytics for store {store_id}: {str(e)}" ) raise AdminOperationException( operation="get_store_analytics", reason=f"Database query failed: {str(e)}", target_type="store", target_id=str(store_id), ) def get_store_statistics(self, db: Session) -> dict: """Get store statistics for admin dashboard. Returns dict compatible with StoreStatsResponse schema. Keys: total, verified, pending, inactive (mapped from internal names) """ try: total_stores = db.query(Store).count() active_stores = db.query(Store).filter(Store.is_active == True).count() verified_stores = ( db.query(Store).filter(Store.is_verified == True).count() ) inactive_stores = total_stores - active_stores # Pending = active but not yet verified pending_stores = ( db.query(Store) .filter(Store.is_active == True, Store.is_verified == False) .count() ) return { # Schema-compatible fields (StoreStatsResponse) "total": total_stores, "verified": verified_stores, "pending": pending_stores, "inactive": inactive_stores, # Legacy fields for backward compatibility "total_stores": total_stores, "active_stores": active_stores, "inactive_stores": inactive_stores, "verified_stores": verified_stores, "pending_stores": pending_stores, "verification_rate": ( (verified_stores / total_stores * 100) if total_stores > 0 else 0 ), } except Exception as e: logger.error(f"Failed to get store statistics: {str(e)}") raise AdminOperationException( operation="get_store_statistics", reason="Database query failed" ) # ======================================================================== # SYSTEM-WIDE STATISTICS (ADMIN) # ======================================================================== def get_comprehensive_stats(self, db: Session) -> dict[str, Any]: """ Get comprehensive system statistics for admin dashboard. Args: db: Database session Returns: Dictionary with comprehensive statistics Raises: AdminOperationException: If database query fails """ try: # Stores total_stores = db.query(Store).filter(Store.is_active == True).count() # Products total_catalog_products = db.query(Product).count() unique_brands = self._get_unique_brands_count(db) unique_categories = self._get_unique_categories_count(db) # Marketplaces unique_marketplaces = ( db.query(MarketplaceProduct.marketplace) .filter(MarketplaceProduct.marketplace.isnot(None)) .distinct() .count() ) # Inventory inventory_stats = self._get_inventory_statistics(db) return { "total_products": total_catalog_products, "unique_brands": unique_brands, "unique_categories": unique_categories, "unique_marketplaces": unique_marketplaces, "unique_stores": total_stores, "total_inventory_entries": inventory_stats.get("total_entries", 0), "total_inventory_quantity": inventory_stats.get("total_quantity", 0), } except Exception as e: logger.error(f"Failed to retrieve comprehensive statistics: {str(e)}") raise AdminOperationException( operation="get_comprehensive_stats", reason=f"Database query failed: {str(e)}", ) def get_marketplace_breakdown_stats(self, db: Session) -> list[dict[str, Any]]: """ Get statistics broken down by marketplace. Args: db: Database session Returns: List of marketplace statistics Raises: AdminOperationException: If database query fails """ try: marketplace_stats = ( db.query( MarketplaceProduct.marketplace, func.count(MarketplaceProduct.id).label("total_products"), func.count(func.distinct(MarketplaceProduct.store_name)).label( "unique_stores" ), func.count(func.distinct(MarketplaceProduct.brand)).label( "unique_brands" ), ) .filter(MarketplaceProduct.marketplace.isnot(None)) .group_by(MarketplaceProduct.marketplace) .all() ) return [ { "marketplace": stat.marketplace, "total_products": stat.total_products, "unique_stores": stat.unique_stores, "unique_brands": stat.unique_brands, } for stat in marketplace_stats ] except Exception as e: logger.error( f"Failed to retrieve marketplace breakdown statistics: {str(e)}" ) raise AdminOperationException( operation="get_marketplace_breakdown_stats", reason=f"Database query failed: {str(e)}", ) def get_user_statistics(self, db: Session) -> dict[str, Any]: """ Get user statistics for admin dashboard. Args: db: Database session Returns: Dictionary with user statistics Raises: AdminOperationException: If database query fails """ try: total_users = db.query(User).count() active_users = db.query(User).filter(User.is_active == True).count() inactive_users = total_users - active_users admin_users = db.query(User).filter(User.role == "admin").count() return { "total_users": total_users, "active_users": active_users, "inactive_users": inactive_users, "admin_users": admin_users, "activation_rate": ( (active_users / total_users * 100) if total_users > 0 else 0 ), } except Exception as e: logger.error(f"Failed to get user statistics: {str(e)}") raise AdminOperationException( operation="get_user_statistics", reason="Database query failed" ) def get_import_statistics(self, db: Session) -> dict[str, Any]: """ Get import job statistics. Args: db: Database session Returns: Dictionary with import statistics Raises: AdminOperationException: If database query fails """ try: total = db.query(MarketplaceImportJob).count() pending = ( db.query(MarketplaceImportJob) .filter(MarketplaceImportJob.status == "pending") .count() ) processing = ( db.query(MarketplaceImportJob) .filter(MarketplaceImportJob.status == "processing") .count() ) completed = ( db.query(MarketplaceImportJob) .filter( MarketplaceImportJob.status.in_( ["completed", "completed_with_errors"] ) ) .count() ) failed = ( db.query(MarketplaceImportJob) .filter(MarketplaceImportJob.status == "failed") .count() ) return { # Frontend-expected fields "total": total, "pending": pending, "processing": processing, "completed": completed, "failed": failed, # Legacy fields for backward compatibility "total_imports": total, "completed_imports": completed, "failed_imports": failed, "success_rate": (completed / total * 100) if total > 0 else 0, } except Exception as e: logger.error(f"Failed to get import statistics: {str(e)}") return { "total": 0, "pending": 0, "processing": 0, "completed": 0, "failed": 0, "total_imports": 0, "completed_imports": 0, "failed_imports": 0, "success_rate": 0, } def get_order_statistics(self, db: Session) -> dict[str, Any]: """ Get order statistics. Args: db: Database session Returns: Dictionary with order statistics Note: TODO: Implement when Order model is fully available """ return {"total_orders": 0, "pending_orders": 0, "completed_orders": 0} def get_product_statistics(self, db: Session) -> dict[str, Any]: """ Get product statistics. Args: db: Database session Returns: Dictionary with product statistics Note: TODO: Implement when Product model is fully available """ return {"total_products": 0, "active_products": 0, "out_of_stock": 0} # ======================================================================== # PRIVATE HELPER METHODS # ======================================================================== def _parse_period(self, period: str) -> int: """ Parse period string to days. Args: period: Period string (7d, 30d, 90d, 1y) Returns: Number of days """ period_map = { "7d": 7, "30d": 30, "90d": 90, "1y": 365, } return period_map.get(period, 30) def _get_unique_brands_count(self, db: Session) -> int: """ Get count of unique brands. Args: db: Database session Returns: Count of unique brands """ return ( db.query(MarketplaceProduct.brand) .filter( MarketplaceProduct.brand.isnot(None), MarketplaceProduct.brand != "" ) .distinct() .count() ) def _get_unique_categories_count(self, db: Session) -> int: """ Get count of unique categories. Args: db: Database session Returns: Count of unique categories """ return ( db.query(MarketplaceProduct.google_product_category) .filter( MarketplaceProduct.google_product_category.isnot(None), MarketplaceProduct.google_product_category != "", ) .distinct() .count() ) def _get_inventory_statistics(self, db: Session) -> dict[str, int]: """ Get inventory-related statistics. Args: db: Database session Returns: Dictionary with inventory statistics """ total_entries = db.query(Inventory).count() total_quantity = db.query(func.sum(Inventory.quantity)).scalar() or 0 total_reserved = db.query(func.sum(Inventory.reserved_quantity)).scalar() or 0 return { "total_entries": total_entries, "total_quantity": int(total_quantity), "total_reserved": int(total_reserved), "total_available": int(total_quantity - total_reserved), } # Create service instance stats_service = StatsService() __all__ = ["stats_service", "StatsService"]