- Replace 153 broad `except Exception` with specific types (SQLAlchemyError, TemplateError, OSError, SMTPException, ClientError, etc.) across 37 services - Break catalog↔inventory circular dependency (IMPORT-004) - Create 19 skeleton test files for MOD-024 coverage - Exclude aggregator services from MOD-024 (false positives) - Update test mocks to match narrowed exception types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
631 lines
21 KiB
Python
631 lines
21 KiB
Python
# 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.exc import SQLAlchemyError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.modules.catalog.models import Product # IMPORT-002
|
|
from app.modules.customers.models.customer import Customer # IMPORT-002
|
|
from app.modules.inventory.models import Inventory # IMPORT-002
|
|
from app.modules.marketplace.models import ( # IMPORT-002
|
|
MarketplaceImportJob,
|
|
MarketplaceProduct,
|
|
)
|
|
from app.modules.orders.models import Order # IMPORT-002
|
|
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 SQLAlchemyError 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 SQLAlchemyError 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 SQLAlchemyError 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 SQLAlchemyError 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 SQLAlchemyError 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 SQLAlchemyError 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 SQLAlchemyError 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"]
|