Files
orion/app/modules/analytics/services/stats_service.py
Samir Boulahtit aad18c27ab
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
refactor: remove all backward compatibility code across 70 files
Clean up 28 backward compatibility instances identified in the codebase.
The app is not live, so all shims are replaced with the target architecture:

- Remove legacy Inventory.location column (use bin_location exclusively)
- Remove dashboard _extract_metric_value helper (use flat metrics dict)
- Remove legacy stat field duplicates (total_stores, total_imports, etc.)
- Remove 13 re-export shims and class aliases across modules
- Remove module-enabling JSON fallback (use PlatformModule junction table)
- Remove menu_to_legacy_format() conversion (return dataclasses directly)
- Remove title/description from MarketplaceProductBase schema
- Clean billing convenience method docstrings
- Clean test fixtures and backward-compat comments
- Add PlatformModule seeding to init_production.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:20:29 +01:00

616 lines
20 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.bin_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 {
"total": total_stores,
"verified": verified_stores,
"pending": pending_stores,
"inactive": inactive_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 {
"total": total,
"pending": pending,
"processing": processing,
"completed": completed,
"failed": 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,
"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"]