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:
@@ -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"])
|
||||
|
||||
163
app/modules/orders/routes/api/vendor_customer_orders.py
Normal file
163
app/modules/orders/routes/api/vendor_customer_orders.py
Normal 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
|
||||
],
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
122
app/modules/orders/services/customer_order_service.py
Normal file
122
app/modules/orders/services/customer_order_service.py
Normal 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"]
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user