feat: implement metrics provider pattern for modular dashboard statistics

This commit introduces a protocol-based metrics architecture that allows
each module to provide its own statistics for dashboards without creating
cross-module dependencies.

Key changes:
- Add MetricsProviderProtocol and MetricValue dataclass in contracts module
- Add StatsAggregatorService in core module that discovers and aggregates
  metrics from all enabled modules
- Implement metrics providers for all modules:
  - tenancy: vendor/user counts, team members, domains
  - customers: customer counts
  - cms: pages, media files
  - catalog: products
  - inventory: stock levels
  - orders: order counts, revenue
  - marketplace: import jobs, staging products
- Update dashboard routes to use StatsAggregator instead of direct imports
- Fix VendorPlatform junction table usage (Vendor.platform_id doesn't exist)
- Add comprehensive documentation for the pattern

This architecture ensures:
- Dashboards always work (aggregator in core)
- Each module owns its metrics (no cross-module coupling)
- Optional modules are truly optional (can be removed without breaking app)
- Multi-platform vendors are properly supported via VendorPlatform table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 21:11:29 +01:00
parent a76128e016
commit a8fae0fbc7
28 changed files with 3745 additions and 269 deletions

View File

@@ -29,6 +29,13 @@ def _get_vendor_router():
return vendor_router
def _get_metrics_provider():
"""Lazy import of metrics provider to avoid circular imports."""
from app.modules.inventory.services.inventory_metrics import inventory_metrics_provider
return inventory_metrics_provider
# Inventory module definition
inventory_module = ModuleDefinition(
code="inventory",
@@ -131,6 +138,8 @@ inventory_module = ModuleDefinition(
models_path="app.modules.inventory.models",
schemas_path="app.modules.inventory.schemas",
exceptions_path="app.modules.inventory.exceptions",
# Metrics provider for dashboard statistics
metrics_provider=_get_metrics_provider,
)

View File

@@ -0,0 +1,318 @@
# app/modules/inventory/services/inventory_metrics.py
"""
Metrics provider for the inventory module.
Provides metrics for:
- Inventory quantities
- Stock levels
- Low stock alerts
"""
import logging
from typing import TYPE_CHECKING
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.contracts.metrics import (
MetricValue,
MetricsContext,
MetricsProviderProtocol,
)
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class InventoryMetricsProvider:
"""
Metrics provider for inventory module.
Provides stock and inventory metrics for vendor and platform dashboards.
"""
@property
def metrics_category(self) -> str:
return "inventory"
def get_vendor_metrics(
self,
db: Session,
vendor_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""
Get inventory metrics for a specific vendor.
Provides:
- Total inventory quantity
- Reserved quantity
- Available quantity
- Inventory locations
- Low stock items
"""
from app.modules.inventory.models import Inventory
try:
# Total inventory
total_quantity = (
db.query(func.sum(Inventory.quantity))
.filter(Inventory.vendor_id == vendor_id)
.scalar()
or 0
)
# Reserved inventory
reserved_quantity = (
db.query(func.sum(Inventory.reserved_quantity))
.filter(Inventory.vendor_id == vendor_id)
.scalar()
or 0
)
# Available inventory
available_quantity = int(total_quantity) - int(reserved_quantity)
# Inventory entries (SKU/location combinations)
inventory_entries = (
db.query(Inventory).filter(Inventory.vendor_id == vendor_id).count()
)
# Unique locations
unique_locations = (
db.query(func.count(func.distinct(Inventory.location)))
.filter(Inventory.vendor_id == vendor_id)
.scalar()
or 0
)
# Low stock items (quantity < 10 and > 0)
low_stock_items = (
db.query(Inventory)
.filter(
Inventory.vendor_id == vendor_id,
Inventory.quantity > 0,
Inventory.quantity < 10,
)
.count()
)
# Out of stock items (quantity = 0)
out_of_stock_items = (
db.query(Inventory)
.filter(Inventory.vendor_id == vendor_id, Inventory.quantity == 0)
.count()
)
return [
MetricValue(
key="inventory.total_quantity",
value=int(total_quantity),
label="Total Stock",
category="inventory",
icon="package",
unit="items",
description="Total inventory quantity",
),
MetricValue(
key="inventory.reserved_quantity",
value=int(reserved_quantity),
label="Reserved",
category="inventory",
icon="lock",
unit="items",
description="Inventory reserved for orders",
),
MetricValue(
key="inventory.available_quantity",
value=available_quantity,
label="Available",
category="inventory",
icon="check",
unit="items",
description="Inventory available for sale",
),
MetricValue(
key="inventory.entries",
value=inventory_entries,
label="SKU/Location Entries",
category="inventory",
icon="list",
description="Total inventory entries",
),
MetricValue(
key="inventory.locations",
value=unique_locations,
label="Locations",
category="inventory",
icon="map-pin",
description="Unique storage locations",
),
MetricValue(
key="inventory.low_stock",
value=low_stock_items,
label="Low Stock",
category="inventory",
icon="alert-triangle",
description="Items with quantity < 10",
),
MetricValue(
key="inventory.out_of_stock",
value=out_of_stock_items,
label="Out of Stock",
category="inventory",
icon="x-circle",
description="Items with zero quantity",
),
]
except Exception as e:
logger.warning(f"Failed to get inventory vendor metrics: {e}")
return []
def get_platform_metrics(
self,
db: Session,
platform_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""
Get inventory metrics aggregated for a platform.
Aggregates stock data across all vendors.
"""
from app.modules.inventory.models import Inventory
from app.modules.tenancy.models import VendorPlatform
try:
# Get all vendor IDs for this platform using VendorPlatform junction table
vendor_ids = (
db.query(VendorPlatform.vendor_id)
.filter(
VendorPlatform.platform_id == platform_id,
VendorPlatform.is_active == True,
)
.subquery()
)
# Total inventory
total_quantity = (
db.query(func.sum(Inventory.quantity))
.filter(Inventory.vendor_id.in_(vendor_ids))
.scalar()
or 0
)
# Reserved inventory
reserved_quantity = (
db.query(func.sum(Inventory.reserved_quantity))
.filter(Inventory.vendor_id.in_(vendor_ids))
.scalar()
or 0
)
# Available inventory
available_quantity = int(total_quantity) - int(reserved_quantity)
# Total inventory entries
inventory_entries = (
db.query(Inventory).filter(Inventory.vendor_id.in_(vendor_ids)).count()
)
# Vendors with inventory
vendors_with_inventory = (
db.query(func.count(func.distinct(Inventory.vendor_id)))
.filter(Inventory.vendor_id.in_(vendor_ids))
.scalar()
or 0
)
# Low stock items across platform
low_stock_items = (
db.query(Inventory)
.filter(
Inventory.vendor_id.in_(vendor_ids),
Inventory.quantity > 0,
Inventory.quantity < 10,
)
.count()
)
# Out of stock items
out_of_stock_items = (
db.query(Inventory)
.filter(Inventory.vendor_id.in_(vendor_ids), Inventory.quantity == 0)
.count()
)
return [
MetricValue(
key="inventory.total_quantity",
value=int(total_quantity),
label="Total Stock",
category="inventory",
icon="package",
unit="items",
description="Total inventory across all vendors",
),
MetricValue(
key="inventory.reserved_quantity",
value=int(reserved_quantity),
label="Reserved",
category="inventory",
icon="lock",
unit="items",
description="Inventory reserved for orders",
),
MetricValue(
key="inventory.available_quantity",
value=available_quantity,
label="Available",
category="inventory",
icon="check",
unit="items",
description="Inventory available for sale",
),
MetricValue(
key="inventory.entries",
value=inventory_entries,
label="Total Entries",
category="inventory",
icon="list",
description="Total inventory entries across vendors",
),
MetricValue(
key="inventory.vendors_with_inventory",
value=vendors_with_inventory,
label="Vendors with Stock",
category="inventory",
icon="store",
description="Vendors managing inventory",
),
MetricValue(
key="inventory.low_stock",
value=low_stock_items,
label="Low Stock Items",
category="inventory",
icon="alert-triangle",
description="Items with quantity < 10",
),
MetricValue(
key="inventory.out_of_stock",
value=out_of_stock_items,
label="Out of Stock",
category="inventory",
icon="x-circle",
description="Items with zero quantity",
),
]
except Exception as e:
logger.warning(f"Failed to get inventory platform metrics: {e}")
return []
# Singleton instance
inventory_metrics_provider = InventoryMetricsProvider()
__all__ = ["InventoryMetricsProvider", "inventory_metrics_provider"]