refactor: fix architecture violations with provider patterns and dependency inversion

Major changes:
- Add AuditProvider protocol for cross-module audit logging
- Move customer order operations to orders module (dependency inversion)
- Add customer order metrics via MetricsProvider pattern
- Fix missing db parameter in get_admin_context() calls
- Move ProductMedia relationship to catalog module (proper ownership)
- Add marketplace breakdown stats to marketplace_widgets

New files:
- contracts/audit.py - AuditProviderProtocol
- core/services/audit_aggregator.py - Aggregates audit providers
- monitoring/services/audit_provider.py - Monitoring audit implementation
- orders/services/customer_order_service.py - Customer order operations
- orders/routes/api/vendor_customer_orders.py - Customer order endpoints
- catalog/services/product_media_service.py - Product media service
- Architecture documentation for patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 21:32:32 +01:00
parent bd43e21940
commit 39dff4ab7d
34 changed files with 2751 additions and 407 deletions

View File

@@ -284,6 +284,9 @@ def ship_order_item(
# ============================================================================
# Import sub-routers
from app.modules.orders.routes.api.vendor_customer_orders import (
vendor_customer_orders_router,
)
from app.modules.orders.routes.api.vendor_exceptions import vendor_exceptions_router
from app.modules.orders.routes.api.vendor_invoices import vendor_invoices_router
@@ -291,3 +294,4 @@ from app.modules.orders.routes.api.vendor_invoices import vendor_invoices_router
vendor_router.include_router(_orders_router, tags=["vendor-orders"])
vendor_router.include_router(vendor_exceptions_router, tags=["vendor-order-exceptions"])
vendor_router.include_router(vendor_invoices_router, tags=["vendor-invoices"])
vendor_router.include_router(vendor_customer_orders_router, tags=["vendor-customer-orders"])

View File

@@ -0,0 +1,163 @@
# app/modules/orders/routes/api/vendor_customer_orders.py
"""
Vendor customer order endpoints.
These endpoints provide customer-order data, owned by the orders module.
The orders module owns the relationship between customers and orders,
similar to how catalog owns the ProductMedia relationship.
Vendor Context: Uses token_vendor_id from JWT token.
"""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.modules.enums import FrontendType
from app.modules.orders.services.customer_order_service import customer_order_service
from app.modules.orders.services.order_metrics import order_metrics_provider
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
# Router for customer-order endpoints
vendor_customer_orders_router = APIRouter(
prefix="/customers",
dependencies=[Depends(require_module_access("orders", FrontendType.VENDOR))],
)
# ============================================================================
# Response Models
# ============================================================================
class CustomerOrderItem(BaseModel):
"""Order summary for customer order list."""
id: int
order_number: str
status: str
total: float
created_at: datetime
class Config:
from_attributes = True
class CustomerOrdersResponse(BaseModel):
"""Response for customer orders list."""
orders: list[CustomerOrderItem]
total: int
skip: int
limit: int
class CustomerOrderStatistic(BaseModel):
"""Single statistic value."""
key: str
value: int | float | str
label: str
icon: str | None = None
unit: str | None = None
class CustomerOrderStatsResponse(BaseModel):
"""Response for customer order statistics."""
customer_id: int
statistics: list[CustomerOrderStatistic]
# ============================================================================
# Endpoints
# ============================================================================
@vendor_customer_orders_router.get(
"/{customer_id}/orders",
response_model=CustomerOrdersResponse,
)
def get_customer_orders(
customer_id: int,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get order history for a specific customer.
This endpoint is owned by the orders module because:
- Orders module owns the Order model and customer-order relationship
- Customers module is agnostic to what uses customers
Similar to how catalog owns ProductMedia (product-media relationship)
while CMS provides generic MediaFile storage.
"""
orders, total = customer_order_service.get_customer_orders(
db=db,
vendor_id=current_user.token_vendor_id,
customer_id=customer_id,
skip=skip,
limit=limit,
)
return CustomerOrdersResponse(
orders=[
CustomerOrderItem(
id=o.id,
order_number=o.order_number,
status=o.status,
total=o.total_cents / 100 if o.total_cents else 0,
created_at=o.created_at,
)
for o in orders
],
total=total,
skip=skip,
limit=limit,
)
@vendor_customer_orders_router.get(
"/{customer_id}/order-stats",
response_model=CustomerOrderStatsResponse,
)
def get_customer_order_stats(
customer_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get order statistics for a specific customer.
Uses the MetricsProvider pattern to provide customer-level order metrics.
Returns statistics like total orders, total spent, average order value, etc.
"""
metrics = order_metrics_provider.get_customer_order_metrics(
db=db,
vendor_id=current_user.token_vendor_id,
customer_id=customer_id,
)
return CustomerOrderStatsResponse(
customer_id=customer_id,
statistics=[
CustomerOrderStatistic(
key=m.key,
value=m.value,
label=m.label,
icon=m.icon,
unit=m.unit,
)
for m in metrics
],
)

View File

@@ -25,6 +25,10 @@ from app.modules.orders.services.invoice_pdf_service import (
invoice_pdf_service,
InvoicePDFService,
)
from app.modules.orders.services.customer_order_service import (
customer_order_service,
CustomerOrderService,
)
__all__ = [
"order_service",
@@ -37,4 +41,6 @@ __all__ = [
"InvoiceService",
"invoice_pdf_service",
"InvoicePDFService",
"customer_order_service",
"CustomerOrderService",
]

View File

@@ -0,0 +1,122 @@
# app/modules/orders/services/customer_order_service.py
"""
Customer-order service for managing customer-order relationships.
This service handles the orders-specific logic for customer order operations.
It follows the principle that:
- Customers module provides generic customer storage (agnostic to consumers)
- Orders module defines how it relates to customers (owns the relationship)
Similar to how:
- CMS provides generic media storage
- Catalog defines ProductMedia association
"""
import logging
from sqlalchemy.orm import Session
from app.modules.orders.models import Order
logger = logging.getLogger(__name__)
class CustomerOrderService:
"""Service for customer-order operations owned by orders module."""
def get_customer_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
skip: int = 0,
limit: int = 50,
) -> tuple[list[Order], int]:
"""
Get orders for a specific customer.
Args:
db: Database session
vendor_id: Vendor ID (for ownership verification)
customer_id: Customer ID
skip: Pagination offset
limit: Pagination limit
Returns:
Tuple of (orders, total_count)
"""
query = (
db.query(Order)
.filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
.order_by(Order.created_at.desc())
)
total = query.count()
orders = query.offset(skip).limit(limit).all()
return orders, total
def get_recent_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
limit: int = 5,
) -> list[Order]:
"""
Get recent orders for a customer.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
limit: Maximum orders to return
Returns:
List of recent orders
"""
return (
db.query(Order)
.filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
.order_by(Order.created_at.desc())
.limit(limit)
.all()
)
def get_order_count(
self,
db: Session,
vendor_id: int,
customer_id: int,
) -> int:
"""
Get total order count for a customer.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
Returns:
Total order count
"""
return (
db.query(Order)
.filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
.count()
)
# Singleton instance
customer_order_service = CustomerOrderService()
__all__ = ["CustomerOrderService", "customer_order_service"]

View File

@@ -302,6 +302,111 @@ class OrderMetricsProvider:
return []
def get_customer_order_metrics(
self,
db: Session,
vendor_id: int,
customer_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""
Get order metrics for a specific customer.
This is an entity-level method (not dashboard-level) that provides
order statistics for a specific customer. Used by customer detail pages.
Args:
db: Database session
vendor_id: Vendor ID (for ownership verification)
customer_id: Customer ID
context: Optional filtering context
Returns:
List of MetricValue objects for this customer's order activity
"""
from app.modules.orders.models import Order
try:
# Base query for customer orders
base_query = db.query(Order).filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
# Total orders
total_orders = base_query.count()
# Revenue stats
revenue_query = db.query(
func.sum(Order.total_amount_cents).label("total_spent_cents"),
func.avg(Order.total_amount_cents).label("avg_order_cents"),
func.max(Order.created_at).label("last_order_date"),
func.min(Order.created_at).label("first_order_date"),
).filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
stats = revenue_query.first()
total_spent_cents = stats.total_spent_cents or 0
avg_order_cents = stats.avg_order_cents or 0
last_order_date = stats.last_order_date
first_order_date = stats.first_order_date
# Convert cents to currency
total_spent = total_spent_cents / 100
avg_order_value = avg_order_cents / 100 if avg_order_cents else 0.0
return [
MetricValue(
key="customer.total_orders",
value=total_orders,
label="Total Orders",
category="customer_orders",
icon="shopping-bag",
description="Total orders placed by this customer",
),
MetricValue(
key="customer.total_spent",
value=round(total_spent, 2),
label="Total Spent",
category="customer_orders",
icon="currency-euro",
unit="EUR",
description="Total amount spent by this customer",
),
MetricValue(
key="customer.avg_order_value",
value=round(avg_order_value, 2),
label="Avg Order Value",
category="customer_orders",
icon="calculator",
unit="EUR",
description="Average order value for this customer",
),
MetricValue(
key="customer.last_order_date",
value=last_order_date.isoformat() if last_order_date else "",
label="Last Order",
category="customer_orders",
icon="calendar",
description="Date of most recent order",
),
MetricValue(
key="customer.first_order_date",
value=first_order_date.isoformat() if first_order_date else "",
label="First Order",
category="customer_orders",
icon="calendar-plus",
description="Date of first order",
),
]
except Exception as e:
logger.warning(f"Failed to get customer order metrics: {e}")
return []
# Singleton instance
order_metrics_provider = OrderMetricsProvider()