refactor: switch to full auto-discovery for module API routes
- Enhanced route discovery system with ROUTE_CONFIG support for custom
prefix, tags, and priority
- Added get_admin_api_routes() and get_vendor_api_routes() helpers that
return routes sorted by priority
- Added fallback discovery for routes/{frontend}.py when routes/api/
doesn't exist
- Updated CMS module with ROUTE_CONFIG (prefix: /content-pages,
priority: 100) to register last for catch-all routes
- Moved customers routes from routes/ to routes/api/ directory
- Updated orders module to aggregate exception routers into main routers
- Removed manual module router imports from admin and vendor API init
files, replaced with auto-discovery loop
Modules now auto-discovered: billing, inventory, orders, marketplace,
cms, customers, analytics, loyalty, messaging, monitoring, dev-tools
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,22 +25,18 @@ Routes can be module-gated using require_module_access() dependency.
|
||||
For multi-tenant apps, module enablement is checked at request time
|
||||
based on platform context (not at route registration time).
|
||||
|
||||
Extracted modules (app/modules/{module}/routes/):
|
||||
Self-contained modules (auto-discovered from app/modules/{module}/routes/api/admin.py):
|
||||
- billing: Subscription tiers, vendor billing, invoices
|
||||
- inventory: Stock management, inventory tracking
|
||||
- orders: Order management, fulfillment, exceptions
|
||||
- marketplace: Letzshop integration, product sync
|
||||
|
||||
Module extraction pattern:
|
||||
1. Create app/modules/{module}/ directory
|
||||
2. Create routes/admin.py with require_module_access("{module}") dependency
|
||||
3. Import module router here and include it
|
||||
4. Comment out legacy router include
|
||||
- cms: Content pages management
|
||||
- customers: Customer management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Import all admin routers
|
||||
# Import all admin routers (legacy routes that haven't been migrated to modules)
|
||||
from . import (
|
||||
admin_users,
|
||||
audit,
|
||||
@@ -48,16 +44,11 @@ from . import (
|
||||
background_tasks,
|
||||
code_quality,
|
||||
companies,
|
||||
# content_pages - moved to app.modules.cms.routes.api.admin
|
||||
# customers - moved to app.modules.customers.routes.admin
|
||||
dashboard,
|
||||
email_templates,
|
||||
features,
|
||||
images,
|
||||
inventory,
|
||||
letzshop,
|
||||
logs,
|
||||
marketplace,
|
||||
media,
|
||||
menu_config,
|
||||
messages,
|
||||
@@ -65,8 +56,6 @@ from . import (
|
||||
modules,
|
||||
monitoring,
|
||||
notifications,
|
||||
order_item_exceptions,
|
||||
orders,
|
||||
platform_health,
|
||||
platforms,
|
||||
products,
|
||||
@@ -80,21 +69,6 @@ from . import (
|
||||
vendors,
|
||||
)
|
||||
|
||||
# Import extracted module routers
|
||||
# NOTE: Import directly from admin.py files to avoid circular imports through __init__.py
|
||||
from app.modules.billing.routes.api.admin import admin_router as billing_admin_router
|
||||
from app.modules.inventory.routes.admin import admin_router as inventory_admin_router
|
||||
from app.modules.orders.routes.admin import admin_router as orders_admin_router
|
||||
from app.modules.orders.routes.admin import admin_exceptions_router as orders_exceptions_router
|
||||
from app.modules.marketplace.routes.api.admin import admin_router as marketplace_admin_router
|
||||
from app.modules.marketplace.routes.api.admin import admin_letzshop_router as letzshop_admin_router
|
||||
|
||||
# CMS module router
|
||||
from app.modules.cms.routes.api.admin import router as cms_admin_router
|
||||
|
||||
# Customers module router
|
||||
from app.modules.customers.routes.admin import admin_router as customers_admin_router
|
||||
|
||||
# Create admin router
|
||||
router = APIRouter()
|
||||
|
||||
@@ -123,12 +97,6 @@ router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
|
||||
# Include vendor themes management endpoints
|
||||
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
|
||||
|
||||
# Include CMS module router (self-contained module)
|
||||
router.include_router(
|
||||
cms_admin_router, prefix="/content-pages", tags=["admin-content-pages"]
|
||||
)
|
||||
# Legacy: content_pages.router moved to app.modules.cms.routes.api.admin
|
||||
|
||||
# Include platforms management endpoints (multi-platform CMS)
|
||||
router.include_router(platforms.router, tags=["admin-platforms"])
|
||||
|
||||
@@ -152,10 +120,6 @@ router.include_router(users.router, tags=["admin-users"])
|
||||
# Include admin user management endpoints (super admin only)
|
||||
router.include_router(admin_users.router, tags=["admin-admin-users"])
|
||||
|
||||
# Include customers module router (with module access control)
|
||||
router.include_router(customers_admin_router, tags=["admin-customers"])
|
||||
# Legacy: router.include_router(customers.router, tags=["admin-customers"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dashboard & Statistics
|
||||
@@ -166,7 +130,7 @@ router.include_router(dashboard.router, tags=["admin-dashboard"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Vendor Operations (Product Catalog, Inventory & Orders) - Module-gated
|
||||
# Vendor Operations (Product Catalog)
|
||||
# ============================================================================
|
||||
|
||||
# Include marketplace product catalog management endpoints
|
||||
@@ -175,27 +139,6 @@ router.include_router(products.router, tags=["admin-marketplace-products"])
|
||||
# Include vendor product catalog management endpoints
|
||||
router.include_router(vendor_products.router, tags=["admin-vendor-products"])
|
||||
|
||||
# Include inventory module router (with module access control)
|
||||
router.include_router(inventory_admin_router, tags=["admin-inventory"])
|
||||
# Legacy: router.include_router(inventory.router, tags=["admin-inventory"])
|
||||
|
||||
# Include orders module router (with module access control)
|
||||
router.include_router(orders_admin_router, tags=["admin-orders"])
|
||||
router.include_router(orders_exceptions_router, tags=["admin-order-exceptions"])
|
||||
# Legacy: router.include_router(orders.router, tags=["admin-orders"])
|
||||
# Legacy: router.include_router(order_item_exceptions.router, tags=["admin-order-exceptions"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Marketplace & Imports (Module-gated)
|
||||
# ============================================================================
|
||||
|
||||
# Include marketplace module router (with module access control)
|
||||
router.include_router(marketplace_admin_router, tags=["admin-marketplace"])
|
||||
router.include_router(letzshop_admin_router, tags=["admin-letzshop"])
|
||||
# Legacy: router.include_router(marketplace.router, tags=["admin-marketplace"])
|
||||
# Legacy: router.include_router(letzshop.router, tags=["admin-letzshop"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Administration
|
||||
@@ -236,21 +179,6 @@ router.include_router(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Billing & Subscriptions (Module-gated)
|
||||
# ============================================================================
|
||||
|
||||
# Include billing module router (with module access control)
|
||||
# This router checks if the 'billing' module is enabled for the platform
|
||||
router.include_router(billing_admin_router, tags=["admin-billing"])
|
||||
|
||||
# Legacy subscriptions router (to be removed once billing module is fully tested)
|
||||
# router.include_router(subscriptions.router, tags=["admin-subscriptions"])
|
||||
|
||||
# Include feature management endpoints
|
||||
router.include_router(features.router, tags=["admin-features"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Code Quality & Architecture
|
||||
# ============================================================================
|
||||
@@ -263,5 +191,31 @@ router.include_router(
|
||||
# Include test runner endpoints
|
||||
router.include_router(tests.router, prefix="/tests", tags=["admin-tests"])
|
||||
|
||||
# Include feature management endpoints
|
||||
router.include_router(features.router, tags=["admin-features"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Auto-discovered Module Routes
|
||||
# ============================================================================
|
||||
# Routes from self-contained modules are auto-discovered and registered.
|
||||
# Modules include: billing, inventory, orders, marketplace, cms, customers
|
||||
|
||||
from app.modules.routes import get_admin_api_routes
|
||||
|
||||
for route_info in get_admin_api_routes():
|
||||
# Only pass prefix if custom_prefix is set (router already has internal prefix)
|
||||
if route_info.custom_prefix:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
prefix=route_info.custom_prefix,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
else:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
|
||||
# Export the router
|
||||
__all__ = ["router"]
|
||||
|
||||
90
app/api/v1/vendor/__init__.py
vendored
90
app/api/v1/vendor/__init__.py
vendored
@@ -14,43 +14,32 @@ Routes can be module-gated using require_module_access() dependency.
|
||||
For multi-tenant apps, module enablement is checked at request time
|
||||
based on platform context (not at route registration time).
|
||||
|
||||
Extracted modules (app/modules/{module}/routes/):
|
||||
Self-contained modules (auto-discovered from app/modules/{module}/routes/api/vendor.py):
|
||||
- billing: Subscription tiers, vendor billing, invoices
|
||||
- inventory: Stock management, inventory tracking
|
||||
- orders: Order management, fulfillment, exceptions
|
||||
- marketplace: Letzshop integration, product sync
|
||||
|
||||
Module extraction pattern:
|
||||
1. Create app/modules/{module}/ directory
|
||||
2. Create routes/vendor.py with require_module_access("{module}") dependency
|
||||
3. Import module router here and include it
|
||||
4. Comment out legacy router include
|
||||
- cms: Content pages management
|
||||
- customers: Customer management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Import all sub-routers (JSON API only)
|
||||
# Import all sub-routers (legacy routes that haven't been migrated to modules)
|
||||
from . import (
|
||||
analytics,
|
||||
auth,
|
||||
billing,
|
||||
# content_pages - moved to app.modules.cms.routes.api.vendor
|
||||
# customers - moved to app.modules.customers.routes.vendor
|
||||
dashboard,
|
||||
email_settings,
|
||||
email_templates,
|
||||
features,
|
||||
info,
|
||||
inventory,
|
||||
invoices,
|
||||
letzshop,
|
||||
marketplace,
|
||||
media,
|
||||
messages,
|
||||
notifications,
|
||||
onboarding,
|
||||
order_item_exceptions,
|
||||
orders,
|
||||
payments,
|
||||
products,
|
||||
profile,
|
||||
@@ -59,21 +48,6 @@ from . import (
|
||||
usage,
|
||||
)
|
||||
|
||||
# Import extracted module routers
|
||||
# NOTE: Import directly from vendor.py files to avoid circular imports through __init__.py
|
||||
from app.modules.billing.routes.api.vendor import vendor_router as billing_vendor_router
|
||||
from app.modules.inventory.routes.vendor import vendor_router as inventory_vendor_router
|
||||
from app.modules.orders.routes.vendor import vendor_router as orders_vendor_router
|
||||
from app.modules.orders.routes.vendor import vendor_exceptions_router as orders_exceptions_router
|
||||
from app.modules.marketplace.routes.api.vendor import vendor_router as marketplace_vendor_router
|
||||
from app.modules.marketplace.routes.api.vendor import vendor_letzshop_router as letzshop_vendor_router
|
||||
|
||||
# CMS module router
|
||||
from app.modules.cms.routes.api.vendor import router as cms_vendor_router
|
||||
|
||||
# Customers module router
|
||||
from app.modules.customers.routes.vendor import vendor_router as customers_vendor_router
|
||||
|
||||
# Create vendor router
|
||||
router = APIRouter()
|
||||
|
||||
@@ -97,50 +71,44 @@ router.include_router(email_templates.router, tags=["vendor-email-templates"])
|
||||
router.include_router(email_settings.router, tags=["vendor-email-settings"])
|
||||
router.include_router(onboarding.router, tags=["vendor-onboarding"])
|
||||
|
||||
# Business operations (with prefixes: /products/*, /orders/*, etc.)
|
||||
# Business operations (with prefixes: /products/*, etc.)
|
||||
router.include_router(products.router, tags=["vendor-products"])
|
||||
|
||||
# Include orders module router (with module access control)
|
||||
router.include_router(orders_vendor_router, tags=["vendor-orders"])
|
||||
router.include_router(orders_exceptions_router, tags=["vendor-order-exceptions"])
|
||||
# Legacy: router.include_router(orders.router, tags=["vendor-orders"])
|
||||
# Legacy: router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"])
|
||||
|
||||
router.include_router(invoices.router, tags=["vendor-invoices"])
|
||||
|
||||
# Include customers module router (with module access control)
|
||||
router.include_router(customers_vendor_router, tags=["vendor-customers"])
|
||||
# Legacy: router.include_router(customers.router, tags=["vendor-customers"])
|
||||
|
||||
router.include_router(team.router, tags=["vendor-team"])
|
||||
|
||||
# Include inventory module router (with module access control)
|
||||
router.include_router(inventory_vendor_router, tags=["vendor-inventory"])
|
||||
# Legacy: router.include_router(inventory.router, tags=["vendor-inventory"])
|
||||
|
||||
# Include marketplace module router (with module access control)
|
||||
router.include_router(marketplace_vendor_router, tags=["vendor-marketplace"])
|
||||
router.include_router(letzshop_vendor_router, tags=["vendor-letzshop"])
|
||||
# Legacy: router.include_router(marketplace.router, tags=["vendor-marketplace"])
|
||||
# Legacy: router.include_router(letzshop.router, tags=["vendor-letzshop"])
|
||||
|
||||
# Services (with prefixes: /payments/*, /media/*, etc.)
|
||||
router.include_router(payments.router, tags=["vendor-payments"])
|
||||
router.include_router(media.router, tags=["vendor-media"])
|
||||
router.include_router(notifications.router, tags=["vendor-notifications"])
|
||||
router.include_router(messages.router, tags=["vendor-messages"])
|
||||
router.include_router(analytics.router, tags=["vendor-analytics"])
|
||||
|
||||
# Include billing module router (with module access control)
|
||||
router.include_router(billing_vendor_router, tags=["vendor-billing"])
|
||||
# Legacy: router.include_router(billing.router, tags=["vendor-billing"])
|
||||
|
||||
router.include_router(features.router, tags=["vendor-features"])
|
||||
router.include_router(usage.router, tags=["vendor-usage"])
|
||||
|
||||
# CMS module router (self-contained module)
|
||||
router.include_router(cms_vendor_router, tags=["vendor-content-pages"])
|
||||
# Legacy: content_pages.router moved to app.modules.cms.routes.api.vendor
|
||||
|
||||
# ============================================================================
|
||||
# Auto-discovered Module Routes
|
||||
# ============================================================================
|
||||
# Routes from self-contained modules are auto-discovered and registered.
|
||||
# Modules include: billing, inventory, orders, marketplace, cms, customers
|
||||
# Routes are sorted by priority, so catch-all routes (CMS) come last.
|
||||
|
||||
from app.modules.routes import get_vendor_api_routes
|
||||
|
||||
for route_info in get_vendor_api_routes():
|
||||
# Only pass prefix if custom_prefix is set (router already has internal prefix)
|
||||
if route_info.custom_prefix:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
prefix=route_info.custom_prefix,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
else:
|
||||
router.include_router(
|
||||
route_info.router,
|
||||
tags=route_info.tags,
|
||||
)
|
||||
|
||||
|
||||
# Vendor info endpoint - MUST BE LAST! Has catch-all GET /{vendor_code}
|
||||
router.include_router(info.router, tags=["vendor-info"])
|
||||
|
||||
@@ -28,8 +28,10 @@ from app.modules.billing.models import FeatureCode
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/analytics",
|
||||
dependencies=[Depends(require_module_access("analytics"))],
|
||||
)
|
||||
vendor_router = router # Alias for discovery
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,15 @@ from app.modules.cms.schemas import (
|
||||
from app.modules.cms.services import content_page_service
|
||||
from models.database.user import User
|
||||
|
||||
# Route configuration for auto-discovery
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "/content-pages",
|
||||
"tags": ["admin-content-pages"],
|
||||
"priority": 100, # Register last (CMS has catch-all slug routes)
|
||||
}
|
||||
|
||||
router = APIRouter()
|
||||
admin_router = router # Alias for discovery compatibility
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -28,9 +28,17 @@ from app.modules.cms.services import content_page_service
|
||||
from app.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service
|
||||
from models.database.user import User
|
||||
|
||||
# Route configuration for auto-discovery
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "/content-pages",
|
||||
"tags": ["vendor-content-pages"],
|
||||
"priority": 100, # Register last (CMS has catch-all slug routes)
|
||||
}
|
||||
|
||||
vendor_service = VendorService()
|
||||
|
||||
router = APIRouter()
|
||||
vendor_router = router # Alias for discovery compatibility
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -6,4 +6,20 @@ from app.modules.customers.routes.api.storefront import router as storefront_rou
|
||||
# Tag for OpenAPI documentation
|
||||
STOREFRONT_TAG = "Customer Account (Storefront)"
|
||||
|
||||
__all__ = ["storefront_router", "STOREFRONT_TAG"]
|
||||
__all__ = [
|
||||
"storefront_router",
|
||||
"STOREFRONT_TAG",
|
||||
"admin_router",
|
||||
"vendor_router",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import routers to avoid circular dependencies."""
|
||||
if name == "admin_router":
|
||||
from app.modules.customers.routes.api.admin import admin_router
|
||||
return admin_router
|
||||
elif name == "vendor_router":
|
||||
from app.modules.customers.routes.api.vendor import vendor_router
|
||||
return vendor_router
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# app/modules/customers/routes/admin.py
|
||||
# app/modules/customers/routes/api/admin.py
|
||||
"""
|
||||
Customer management endpoints for admin.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# app/modules/customers/routes/vendor.py
|
||||
# app/modules/customers/routes/api/vendor.py
|
||||
"""
|
||||
Vendor customer management endpoints.
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
# app/modules/orders/routes/api/__init__.py
|
||||
"""Orders module API routes."""
|
||||
"""
|
||||
Orders module API routes.
|
||||
|
||||
Provides REST API endpoints for order management:
|
||||
- Admin API: Platform-wide order management (includes exceptions)
|
||||
- Vendor API: Vendor-specific order operations (includes exceptions)
|
||||
- Storefront API: Customer-facing order endpoints
|
||||
|
||||
Note: admin_router and vendor_router now aggregate their respective
|
||||
exception routers, so only these two routers need to be registered.
|
||||
"""
|
||||
|
||||
from app.modules.orders.routes.api.storefront import router as storefront_router
|
||||
|
||||
# Tag for OpenAPI documentation
|
||||
STOREFRONT_TAG = "Orders (Storefront)"
|
||||
|
||||
__all__ = ["storefront_router", "STOREFRONT_TAG"]
|
||||
__all__ = [
|
||||
"storefront_router",
|
||||
"STOREFRONT_TAG",
|
||||
"admin_router",
|
||||
"vendor_router",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import routers to avoid circular dependencies."""
|
||||
if name == "admin_router":
|
||||
from app.modules.orders.routes.api.admin import admin_router
|
||||
return admin_router
|
||||
elif name == "vendor_router":
|
||||
from app.modules.orders.routes.api.vendor import vendor_router
|
||||
return vendor_router
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
214
app/modules/orders/routes/api/admin.py
Normal file
214
app/modules/orders/routes/api/admin.py
Normal file
@@ -0,0 +1,214 @@
|
||||
# app/modules/orders/routes/api/admin.py
|
||||
"""
|
||||
Admin order management endpoints.
|
||||
|
||||
Provides order management capabilities for administrators:
|
||||
- View orders across all vendors
|
||||
- View vendor-specific orders
|
||||
- Update order status on behalf of vendors
|
||||
- Order statistics and reporting
|
||||
|
||||
Admin Context: Uses admin JWT authentication.
|
||||
Vendor selection is passed as a request parameter.
|
||||
|
||||
This router aggregates both order routes and exception routes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.services.order_service import order_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
AdminOrderItem,
|
||||
AdminOrderListResponse,
|
||||
AdminOrderStats,
|
||||
AdminOrderStatusUpdate,
|
||||
AdminVendorsWithOrdersResponse,
|
||||
MarkAsShippedRequest,
|
||||
OrderDetailResponse,
|
||||
ShippingLabelInfo,
|
||||
)
|
||||
|
||||
# Base router for orders
|
||||
_orders_router = APIRouter(
|
||||
prefix="/orders",
|
||||
dependencies=[Depends(require_module_access("orders"))],
|
||||
)
|
||||
|
||||
# Aggregate router that includes both orders and exceptions
|
||||
admin_router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# List & Statistics Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@_orders_router.get("", response_model=AdminOrderListResponse)
|
||||
def get_all_orders(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
channel: str | None = Query(None, description="Filter by channel"),
|
||||
search: str | None = Query(None, description="Search by order number or customer"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get orders across all vendors with filtering.
|
||||
|
||||
Allows admins to view and filter orders across the platform.
|
||||
"""
|
||||
orders, total = order_service.get_all_orders_admin(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
vendor_id=vendor_id,
|
||||
status=status,
|
||||
channel=channel,
|
||||
search=search,
|
||||
)
|
||||
|
||||
return AdminOrderListResponse(
|
||||
orders=[AdminOrderItem(**order) for order in orders],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@_orders_router.get("/stats", response_model=AdminOrderStats)
|
||||
def get_order_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get platform-wide order statistics."""
|
||||
return order_service.get_order_stats_admin(db)
|
||||
|
||||
|
||||
@_orders_router.get("/vendors", response_model=AdminVendorsWithOrdersResponse)
|
||||
def get_vendors_with_orders(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get list of vendors that have orders."""
|
||||
vendors = order_service.get_vendors_with_orders_admin(db)
|
||||
return AdminVendorsWithOrdersResponse(vendors=vendors)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Order Detail & Update Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@_orders_router.get("/{order_id}", response_model=OrderDetailResponse)
|
||||
def get_order_detail(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get order details including items and addresses."""
|
||||
order = order_service.get_order_by_id_admin(db, order_id)
|
||||
|
||||
# Enrich with vendor info
|
||||
response = OrderDetailResponse.model_validate(order)
|
||||
if order.vendor:
|
||||
response.vendor_name = order.vendor.name
|
||||
response.vendor_code = order.vendor.vendor_code
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@_orders_router.patch("/{order_id}/status", response_model=OrderDetailResponse)
|
||||
def update_order_status(
|
||||
order_id: int,
|
||||
status_update: AdminOrderStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update order status.
|
||||
|
||||
Admin can update status and add tracking number.
|
||||
Status changes are logged with optional reason.
|
||||
"""
|
||||
order = order_service.update_order_status_admin(
|
||||
db=db,
|
||||
order_id=order_id,
|
||||
status=status_update.status,
|
||||
tracking_number=status_update.tracking_number,
|
||||
reason=status_update.reason,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.email} updated order {order.order_number} "
|
||||
f"status to {status_update.status}"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return order
|
||||
|
||||
|
||||
@_orders_router.post("/{order_id}/ship", response_model=OrderDetailResponse)
|
||||
def mark_order_as_shipped(
|
||||
order_id: int,
|
||||
ship_request: MarkAsShippedRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Mark an order as shipped with optional tracking information.
|
||||
|
||||
This endpoint:
|
||||
- Sets order status to 'shipped'
|
||||
- Sets shipped_at timestamp
|
||||
- Optionally stores tracking number, URL, and carrier
|
||||
"""
|
||||
order = order_service.mark_as_shipped_admin(
|
||||
db=db,
|
||||
order_id=order_id,
|
||||
tracking_number=ship_request.tracking_number,
|
||||
tracking_url=ship_request.tracking_url,
|
||||
shipping_carrier=ship_request.shipping_carrier,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.email} marked order {order.order_number} as shipped"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return order
|
||||
|
||||
|
||||
@_orders_router.get("/{order_id}/shipping-label", response_model=ShippingLabelInfo)
|
||||
def get_shipping_label_info(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get shipping label information for an order.
|
||||
|
||||
Returns the shipment number, carrier, and generated label URL
|
||||
based on carrier settings.
|
||||
"""
|
||||
return order_service.get_shipping_label_info_admin(db, order_id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Aggregate routers
|
||||
# ============================================================================
|
||||
|
||||
# Import exceptions router
|
||||
from app.modules.orders.routes.api.admin_exceptions import admin_exceptions_router
|
||||
|
||||
# Include both routers into the aggregate admin_router
|
||||
admin_router.include_router(_orders_router, tags=["admin-orders"])
|
||||
admin_router.include_router(admin_exceptions_router, tags=["admin-order-exceptions"])
|
||||
256
app/modules/orders/routes/api/admin_exceptions.py
Normal file
256
app/modules/orders/routes/api/admin_exceptions.py
Normal file
@@ -0,0 +1,256 @@
|
||||
# app/modules/orders/routes/api/admin_exceptions.py
|
||||
"""
|
||||
Admin API endpoints for order item exception management.
|
||||
|
||||
Provides admin-level management of:
|
||||
- Listing exceptions across all vendors
|
||||
- Resolving exceptions by assigning products
|
||||
- Bulk resolution by GTIN
|
||||
- Exception statistics
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.services.order_item_exception_service import order_item_exception_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
BulkResolveRequest,
|
||||
BulkResolveResponse,
|
||||
IgnoreExceptionRequest,
|
||||
OrderItemExceptionListResponse,
|
||||
OrderItemExceptionResponse,
|
||||
OrderItemExceptionStats,
|
||||
ResolveExceptionRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
admin_exceptions_router = APIRouter(
|
||||
prefix="/order-exceptions",
|
||||
tags=["Order Item Exceptions"],
|
||||
dependencies=[Depends(require_module_access("orders"))],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Listing and Stats
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_exceptions_router.get("", response_model=OrderItemExceptionListResponse)
|
||||
def list_exceptions(
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||
status: str | None = Query(
|
||||
None,
|
||||
pattern="^(pending|resolved|ignored)$",
|
||||
description="Filter by status"
|
||||
),
|
||||
search: str | None = Query(
|
||||
None,
|
||||
description="Search in GTIN, product name, SKU, or order number"
|
||||
),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List order item exceptions with filtering and pagination.
|
||||
|
||||
Returns exceptions for unmatched products during marketplace order imports.
|
||||
"""
|
||||
exceptions, total = order_item_exception_service.get_pending_exceptions(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
status=status,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Enrich with order and vendor info
|
||||
response_items = []
|
||||
for exc in exceptions:
|
||||
item = OrderItemExceptionResponse.model_validate(exc)
|
||||
if exc.order_item and exc.order_item.order:
|
||||
order = exc.order_item.order
|
||||
item.order_number = order.order_number
|
||||
item.order_id = order.id
|
||||
item.order_date = order.order_date
|
||||
item.order_status = order.status
|
||||
# Add vendor name for cross-vendor view
|
||||
if order.vendor:
|
||||
item.vendor_name = order.vendor.name
|
||||
response_items.append(item)
|
||||
|
||||
return OrderItemExceptionListResponse(
|
||||
exceptions=response_items,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@admin_exceptions_router.get("/stats", response_model=OrderItemExceptionStats)
|
||||
def get_exception_stats(
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get exception statistics.
|
||||
|
||||
Returns counts of pending, resolved, and ignored exceptions.
|
||||
"""
|
||||
stats = order_item_exception_service.get_exception_stats(db, vendor_id)
|
||||
return OrderItemExceptionStats(**stats)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Details
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_exceptions_router.get("/{exception_id}", response_model=OrderItemExceptionResponse)
|
||||
def get_exception(
|
||||
exception_id: int = Path(..., description="Exception ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get details of a single exception.
|
||||
"""
|
||||
exception = order_item_exception_service.get_exception_by_id(db, exception_id)
|
||||
|
||||
response = OrderItemExceptionResponse.model_validate(exception)
|
||||
if exception.order_item and exception.order_item.order:
|
||||
order = exception.order_item.order
|
||||
response.order_number = order.order_number
|
||||
response.order_id = order.id
|
||||
response.order_date = order.order_date
|
||||
response.order_status = order.status
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Resolution
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_exceptions_router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse)
|
||||
def resolve_exception(
|
||||
exception_id: int = Path(..., description="Exception ID"),
|
||||
request: ResolveExceptionRequest = ...,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Resolve an exception by assigning a product.
|
||||
|
||||
This updates the order item's product_id and marks the exception as resolved.
|
||||
"""
|
||||
exception = order_item_exception_service.resolve_exception(
|
||||
db=db,
|
||||
exception_id=exception_id,
|
||||
product_id=request.product_id,
|
||||
resolved_by=current_admin.id,
|
||||
notes=request.notes,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = OrderItemExceptionResponse.model_validate(exception)
|
||||
if exception.order_item and exception.order_item.order:
|
||||
order = exception.order_item.order
|
||||
response.order_number = order.order_number
|
||||
response.order_id = order.id
|
||||
response.order_date = order.order_date
|
||||
response.order_status = order.status
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.id} resolved exception {exception_id} "
|
||||
f"with product {request.product_id}"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@admin_exceptions_router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse)
|
||||
def ignore_exception(
|
||||
exception_id: int = Path(..., description="Exception ID"),
|
||||
request: IgnoreExceptionRequest = ...,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Mark an exception as ignored.
|
||||
|
||||
Note: Ignored exceptions still block order confirmation.
|
||||
Use this when a product will never be matched (e.g., discontinued).
|
||||
"""
|
||||
exception = order_item_exception_service.ignore_exception(
|
||||
db=db,
|
||||
exception_id=exception_id,
|
||||
resolved_by=current_admin.id,
|
||||
notes=request.notes,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = OrderItemExceptionResponse.model_validate(exception)
|
||||
if exception.order_item and exception.order_item.order:
|
||||
order = exception.order_item.order
|
||||
response.order_number = order.order_number
|
||||
response.order_id = order.id
|
||||
response.order_date = order.order_date
|
||||
response.order_status = order.status
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.id} ignored exception {exception_id}: {request.notes}"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bulk Operations
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_exceptions_router.post("/bulk-resolve", response_model=BulkResolveResponse)
|
||||
def bulk_resolve_by_gtin(
|
||||
request: BulkResolveRequest,
|
||||
vendor_id: int = Query(..., description="Vendor ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Bulk resolve all pending exceptions for a GTIN.
|
||||
|
||||
Useful when a new product is imported and multiple orders have
|
||||
items with the same unmatched GTIN.
|
||||
"""
|
||||
resolved_count = order_item_exception_service.bulk_resolve_by_gtin(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
gtin=request.gtin,
|
||||
product_id=request.product_id,
|
||||
resolved_by=current_admin.id,
|
||||
notes=request.notes,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.id} bulk resolved {resolved_count} exceptions "
|
||||
f"for GTIN {request.gtin} with product {request.product_id}"
|
||||
)
|
||||
|
||||
return BulkResolveResponse(
|
||||
resolved_count=resolved_count,
|
||||
gtin=request.gtin,
|
||||
product_id=request.product_id,
|
||||
)
|
||||
290
app/modules/orders/routes/api/vendor.py
Normal file
290
app/modules/orders/routes/api/vendor.py
Normal file
@@ -0,0 +1,290 @@
|
||||
# app/modules/orders/routes/api/vendor.py
|
||||
"""
|
||||
Vendor order management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
|
||||
This router aggregates both order routes and exception routes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
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.services.order_inventory_service import order_inventory_service
|
||||
from app.services.order_service import order_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
OrderDetailResponse,
|
||||
OrderListResponse,
|
||||
OrderResponse,
|
||||
OrderUpdate,
|
||||
)
|
||||
|
||||
# Base router for orders
|
||||
_orders_router = APIRouter(
|
||||
prefix="/orders",
|
||||
dependencies=[Depends(require_module_access("orders"))],
|
||||
)
|
||||
|
||||
# Aggregate router that includes both orders and exceptions
|
||||
vendor_router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@_orders_router.get("", response_model=OrderListResponse)
|
||||
def get_vendor_orders(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
status: str | None = Query(None, description="Filter by order status"),
|
||||
customer_id: int | None = Query(None, description="Filter by customer"),
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get all orders for vendor.
|
||||
|
||||
Supports filtering by:
|
||||
- status: Order status (pending, processing, shipped, delivered, cancelled)
|
||||
- customer_id: Filter orders from specific customer
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
orders, total = order_service.get_vendor_orders(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status,
|
||||
customer_id=customer_id,
|
||||
)
|
||||
|
||||
return OrderListResponse(
|
||||
orders=[OrderResponse.model_validate(o) for o in orders],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@_orders_router.get("/{order_id}", response_model=OrderDetailResponse)
|
||||
def get_order_details(
|
||||
order_id: int,
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get detailed order information including items and addresses.
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
order = order_service.get_order(
|
||||
db=db, vendor_id=current_user.token_vendor_id, order_id=order_id
|
||||
)
|
||||
|
||||
return OrderDetailResponse.model_validate(order)
|
||||
|
||||
|
||||
@_orders_router.put("/{order_id}/status", response_model=OrderResponse)
|
||||
def update_order_status(
|
||||
order_id: int,
|
||||
order_update: OrderUpdate,
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update order status and tracking information.
|
||||
|
||||
Valid statuses:
|
||||
- pending: Order placed, awaiting processing
|
||||
- processing: Order being prepared
|
||||
- shipped: Order shipped to customer
|
||||
- delivered: Order delivered
|
||||
- cancelled: Order cancelled
|
||||
- refunded: Order refunded
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
order = order_service.update_order_status(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
order_id=order_id,
|
||||
order_update=order_update,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Order {order.order_number} status updated to {order.status} "
|
||||
f"by user {current_user.username}"
|
||||
)
|
||||
|
||||
return OrderResponse.model_validate(order)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Partial Shipment Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ShipItemRequest(BaseModel):
|
||||
"""Request to ship specific quantity of an order item."""
|
||||
|
||||
quantity: int | None = Field(
|
||||
None, ge=1, description="Quantity to ship (default: remaining quantity)"
|
||||
)
|
||||
|
||||
|
||||
class ShipItemResponse(BaseModel):
|
||||
"""Response from shipping an item."""
|
||||
|
||||
order_id: int
|
||||
item_id: int
|
||||
fulfilled_quantity: int
|
||||
shipped_quantity: int | None = None
|
||||
remaining_quantity: int | None = None
|
||||
is_fully_shipped: bool | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class ShipmentStatusItemResponse(BaseModel):
|
||||
"""Item-level shipment status."""
|
||||
|
||||
item_id: int
|
||||
product_id: int
|
||||
product_name: str
|
||||
quantity: int
|
||||
shipped_quantity: int
|
||||
remaining_quantity: int
|
||||
is_fully_shipped: bool
|
||||
is_partially_shipped: bool
|
||||
|
||||
|
||||
class ShipmentStatusResponse(BaseModel):
|
||||
"""Order shipment status response."""
|
||||
|
||||
order_id: int
|
||||
order_number: str
|
||||
order_status: str
|
||||
is_fully_shipped: bool
|
||||
is_partially_shipped: bool
|
||||
shipped_item_count: int
|
||||
total_item_count: int
|
||||
total_shipped_units: int
|
||||
total_ordered_units: int
|
||||
items: list[ShipmentStatusItemResponse]
|
||||
|
||||
|
||||
@_orders_router.get("/{order_id}/shipment-status", response_model=ShipmentStatusResponse)
|
||||
def get_shipment_status(
|
||||
order_id: int,
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get detailed shipment status for an order.
|
||||
|
||||
Returns item-level shipment status showing what has been shipped
|
||||
and what remains. Useful for partial shipment tracking.
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
result = order_inventory_service.get_shipment_status(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
return ShipmentStatusResponse(
|
||||
order_id=result["order_id"],
|
||||
order_number=result["order_number"],
|
||||
order_status=result["order_status"],
|
||||
is_fully_shipped=result["is_fully_shipped"],
|
||||
is_partially_shipped=result["is_partially_shipped"],
|
||||
shipped_item_count=result["shipped_item_count"],
|
||||
total_item_count=result["total_item_count"],
|
||||
total_shipped_units=result["total_shipped_units"],
|
||||
total_ordered_units=result["total_ordered_units"],
|
||||
items=[ShipmentStatusItemResponse(**item) for item in result["items"]],
|
||||
)
|
||||
|
||||
|
||||
@_orders_router.post("/{order_id}/items/{item_id}/ship", response_model=ShipItemResponse)
|
||||
def ship_order_item(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
request: ShipItemRequest | None = None,
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Ship a specific order item (supports partial shipment).
|
||||
|
||||
Fulfills inventory and updates the item's shipped quantity.
|
||||
If quantity is not specified, ships the remaining quantity.
|
||||
|
||||
Example use cases:
|
||||
- Ship all of an item: POST /orders/{id}/items/{item_id}/ship
|
||||
- Ship partial: POST /orders/{id}/items/{item_id}/ship with {"quantity": 2}
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
quantity = request.quantity if request else None
|
||||
|
||||
result = order_inventory_service.fulfill_item(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
order_id=order_id,
|
||||
item_id=item_id,
|
||||
quantity=quantity,
|
||||
skip_missing=True,
|
||||
)
|
||||
|
||||
# Update order status based on shipment state
|
||||
order = order_service.get_order(db, current_user.token_vendor_id, order_id)
|
||||
|
||||
if order.is_fully_shipped and order.status != "shipped":
|
||||
order_service.update_order_status(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
order_id=order_id,
|
||||
order_update=OrderUpdate(status="shipped"),
|
||||
)
|
||||
logger.info(f"Order {order.order_number} fully shipped")
|
||||
elif order.is_partially_shipped and order.status not in (
|
||||
"partially_shipped",
|
||||
"shipped",
|
||||
):
|
||||
order_service.update_order_status(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
order_id=order_id,
|
||||
order_update=OrderUpdate(status="partially_shipped"),
|
||||
)
|
||||
logger.info(f"Order {order.order_number} partially shipped")
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Shipped item {item_id} of order {order_id}: "
|
||||
f"{result.get('fulfilled_quantity', 0)} units"
|
||||
)
|
||||
|
||||
return ShipItemResponse(**result)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Aggregate routers
|
||||
# ============================================================================
|
||||
|
||||
# Import exceptions router
|
||||
from app.modules.orders.routes.api.vendor_exceptions import vendor_exceptions_router
|
||||
|
||||
# Include both routers into the aggregate vendor_router
|
||||
vendor_router.include_router(_orders_router, tags=["vendor-orders"])
|
||||
vendor_router.include_router(vendor_exceptions_router, tags=["vendor-order-exceptions"])
|
||||
265
app/modules/orders/routes/api/vendor_exceptions.py
Normal file
265
app/modules/orders/routes/api/vendor_exceptions.py
Normal file
@@ -0,0 +1,265 @@
|
||||
# app/modules/orders/routes/api/vendor_exceptions.py
|
||||
"""
|
||||
Vendor API endpoints for order item exception management.
|
||||
|
||||
Provides vendor-level management of:
|
||||
- Listing vendor's own exceptions
|
||||
- Resolving exceptions by assigning products
|
||||
- Exception statistics for vendor dashboard
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
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.services.order_item_exception_service import order_item_exception_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
BulkResolveRequest,
|
||||
BulkResolveResponse,
|
||||
IgnoreExceptionRequest,
|
||||
OrderItemExceptionListResponse,
|
||||
OrderItemExceptionResponse,
|
||||
OrderItemExceptionStats,
|
||||
ResolveExceptionRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
vendor_exceptions_router = APIRouter(
|
||||
prefix="/order-exceptions",
|
||||
tags=["Vendor Order Item Exceptions"],
|
||||
dependencies=[Depends(require_module_access("orders"))],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Listing and Stats
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@vendor_exceptions_router.get("", response_model=OrderItemExceptionListResponse)
|
||||
def list_vendor_exceptions(
|
||||
status: str | None = Query(
|
||||
None,
|
||||
pattern="^(pending|resolved|ignored)$",
|
||||
description="Filter by status"
|
||||
),
|
||||
search: str | None = Query(
|
||||
None,
|
||||
description="Search in GTIN, product name, SKU, or order number"
|
||||
),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List order item exceptions for the authenticated vendor.
|
||||
|
||||
Returns exceptions for unmatched products during marketplace order imports.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
exceptions, total = order_item_exception_service.get_pending_exceptions(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
status=status,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Enrich with order info
|
||||
response_items = []
|
||||
for exc in exceptions:
|
||||
item = OrderItemExceptionResponse.model_validate(exc)
|
||||
if exc.order_item and exc.order_item.order:
|
||||
order = exc.order_item.order
|
||||
item.order_number = order.order_number
|
||||
item.order_id = order.id
|
||||
item.order_date = order.order_date
|
||||
item.order_status = order.status
|
||||
response_items.append(item)
|
||||
|
||||
return OrderItemExceptionListResponse(
|
||||
exceptions=response_items,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@vendor_exceptions_router.get("/stats", response_model=OrderItemExceptionStats)
|
||||
def get_vendor_exception_stats(
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get exception statistics for the authenticated vendor.
|
||||
|
||||
Returns counts of pending, resolved, and ignored exceptions.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
stats = order_item_exception_service.get_exception_stats(db, vendor_id)
|
||||
return OrderItemExceptionStats(**stats)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Details
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@vendor_exceptions_router.get("/{exception_id}", response_model=OrderItemExceptionResponse)
|
||||
def get_vendor_exception(
|
||||
exception_id: int = Path(..., description="Exception ID"),
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get details of a single exception (vendor-scoped).
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Pass vendor_id for scoped access
|
||||
exception = order_item_exception_service.get_exception_by_id(
|
||||
db, exception_id, vendor_id
|
||||
)
|
||||
|
||||
response = OrderItemExceptionResponse.model_validate(exception)
|
||||
if exception.order_item and exception.order_item.order:
|
||||
order = exception.order_item.order
|
||||
response.order_number = order.order_number
|
||||
response.order_id = order.id
|
||||
response.order_date = order.order_date
|
||||
response.order_status = order.status
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Resolution
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@vendor_exceptions_router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse)
|
||||
def resolve_vendor_exception(
|
||||
exception_id: int = Path(..., description="Exception ID"),
|
||||
request: ResolveExceptionRequest = ...,
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Resolve an exception by assigning a product (vendor-scoped).
|
||||
|
||||
This updates the order item's product_id and marks the exception as resolved.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
exception = order_item_exception_service.resolve_exception(
|
||||
db=db,
|
||||
exception_id=exception_id,
|
||||
product_id=request.product_id,
|
||||
resolved_by=current_user.id,
|
||||
notes=request.notes,
|
||||
vendor_id=vendor_id, # Vendor-scoped access
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = OrderItemExceptionResponse.model_validate(exception)
|
||||
if exception.order_item and exception.order_item.order:
|
||||
order = exception.order_item.order
|
||||
response.order_number = order.order_number
|
||||
response.order_id = order.id
|
||||
response.order_date = order.order_date
|
||||
response.order_status = order.status
|
||||
|
||||
logger.info(
|
||||
f"Vendor user {current_user.id} resolved exception {exception_id} "
|
||||
f"with product {request.product_id}"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@vendor_exceptions_router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse)
|
||||
def ignore_vendor_exception(
|
||||
exception_id: int = Path(..., description="Exception ID"),
|
||||
request: IgnoreExceptionRequest = ...,
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Mark an exception as ignored (vendor-scoped).
|
||||
|
||||
Note: Ignored exceptions still block order confirmation.
|
||||
Use this when a product will never be matched (e.g., discontinued).
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
exception = order_item_exception_service.ignore_exception(
|
||||
db=db,
|
||||
exception_id=exception_id,
|
||||
resolved_by=current_user.id,
|
||||
notes=request.notes,
|
||||
vendor_id=vendor_id, # Vendor-scoped access
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = OrderItemExceptionResponse.model_validate(exception)
|
||||
if exception.order_item and exception.order_item.order:
|
||||
order = exception.order_item.order
|
||||
response.order_number = order.order_number
|
||||
response.order_id = order.id
|
||||
response.order_date = order.order_date
|
||||
response.order_status = order.status
|
||||
|
||||
logger.info(
|
||||
f"Vendor user {current_user.id} ignored exception {exception_id}: {request.notes}"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bulk Operations
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@vendor_exceptions_router.post("/bulk-resolve", response_model=BulkResolveResponse)
|
||||
def bulk_resolve_vendor_exceptions(
|
||||
request: BulkResolveRequest,
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Bulk resolve all pending exceptions for a GTIN (vendor-scoped).
|
||||
|
||||
Useful when a new product is imported and multiple orders have
|
||||
items with the same unmatched GTIN.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
resolved_count = order_item_exception_service.bulk_resolve_by_gtin(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
gtin=request.gtin,
|
||||
product_id=request.product_id,
|
||||
resolved_by=current_user.id,
|
||||
notes=request.notes,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Vendor user {current_user.id} bulk resolved {resolved_count} exceptions "
|
||||
f"for GTIN {request.gtin} with product {request.product_id}"
|
||||
)
|
||||
|
||||
return BulkResolveResponse(
|
||||
resolved_count=resolved_count,
|
||||
gtin=request.gtin,
|
||||
product_id=request.product_id,
|
||||
)
|
||||
@@ -22,11 +22,20 @@ Usage:
|
||||
tags=route_info["tags"],
|
||||
include_in_schema=route_info.get("include_in_schema", True),
|
||||
)
|
||||
|
||||
Route Configuration:
|
||||
Modules can export a ROUTE_CONFIG dict to customize route registration:
|
||||
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "/content-pages", # Custom prefix (replaces default)
|
||||
"tags": ["admin-content-pages"], # Custom tags
|
||||
"priority": 100, # Higher = registered later (for catch-all routes)
|
||||
}
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -47,6 +56,8 @@ class RouteInfo:
|
||||
module_code: str = ""
|
||||
route_type: str = "" # "api" or "pages"
|
||||
frontend: str = "" # "admin", "vendor", "shop"
|
||||
priority: int = 0 # Higher = registered later (for catch-all routes)
|
||||
custom_prefix: str = "" # Custom prefix from ROUTE_CONFIG
|
||||
|
||||
|
||||
def discover_module_routes() -> list[RouteInfo]:
|
||||
@@ -113,9 +124,10 @@ def _discover_module_routes(module_code: str) -> list[RouteInfo]:
|
||||
if not routes_path.exists():
|
||||
return routes
|
||||
|
||||
# Discover API routes
|
||||
# Discover API routes (with fallback to routes/ for legacy modules)
|
||||
api_routes = _discover_routes_in_dir(
|
||||
module_code, dir_name, routes_path / "api", "api"
|
||||
module_code, dir_name, routes_path / "api", "api",
|
||||
fallback_dir=routes_path # Allow routes/admin.py as fallback
|
||||
)
|
||||
routes.extend(api_routes)
|
||||
|
||||
@@ -129,7 +141,8 @@ def _discover_module_routes(module_code: str) -> list[RouteInfo]:
|
||||
|
||||
|
||||
def _discover_routes_in_dir(
|
||||
module_code: str, dir_name: str, routes_dir: Path, route_type: str
|
||||
module_code: str, dir_name: str, routes_dir: Path, route_type: str,
|
||||
fallback_dir: Path | None = None
|
||||
) -> list[RouteInfo]:
|
||||
"""
|
||||
Discover routes in a specific directory (api/ or pages/).
|
||||
@@ -139,15 +152,14 @@ def _discover_routes_in_dir(
|
||||
dir_name: Directory name (module_code with _ instead of -)
|
||||
routes_dir: Path to routes/api/ or routes/pages/
|
||||
route_type: "api" or "pages"
|
||||
fallback_dir: Optional fallback directory (e.g., routes/ for modules
|
||||
with routes/admin.py instead of routes/api/admin.py)
|
||||
|
||||
Returns:
|
||||
List of RouteInfo for discovered routes
|
||||
"""
|
||||
routes: list[RouteInfo] = []
|
||||
|
||||
if not routes_dir.exists():
|
||||
return routes
|
||||
|
||||
# Look for admin.py, vendor.py, shop.py
|
||||
frontends = {
|
||||
"admin": {
|
||||
@@ -168,13 +180,26 @@ def _discover_routes_in_dir(
|
||||
}
|
||||
|
||||
for frontend, config in frontends.items():
|
||||
route_file = routes_dir / f"{frontend}.py"
|
||||
if not route_file.exists():
|
||||
# Check primary location first, then fallback
|
||||
route_file = routes_dir / f"{frontend}.py" if routes_dir.exists() else None
|
||||
use_fallback = False
|
||||
|
||||
if route_file is None or not route_file.exists():
|
||||
if fallback_dir and fallback_dir.exists():
|
||||
fallback_file = fallback_dir / f"{frontend}.py"
|
||||
if fallback_file.exists():
|
||||
route_file = fallback_file
|
||||
use_fallback = True
|
||||
|
||||
if route_file is None or not route_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
# Import the module
|
||||
import_path = f"app.modules.{dir_name}.routes.{route_type}.{frontend}"
|
||||
if use_fallback:
|
||||
import_path = f"app.modules.{dir_name}.routes.{frontend}"
|
||||
else:
|
||||
import_path = f"app.modules.{dir_name}.routes.{route_type}.{frontend}"
|
||||
route_module = importlib.import_module(import_path)
|
||||
|
||||
# Get the router (try common names)
|
||||
@@ -188,14 +213,23 @@ def _discover_routes_in_dir(
|
||||
logger.warning(f"No router found in {import_path}")
|
||||
continue
|
||||
|
||||
# Read ROUTE_CONFIG if present
|
||||
route_config = getattr(route_module, "ROUTE_CONFIG", {})
|
||||
custom_prefix = route_config.get("prefix", "")
|
||||
custom_tags = route_config.get("tags", [])
|
||||
priority = route_config.get("priority", 0)
|
||||
|
||||
# Determine prefix based on route type
|
||||
if route_type == "api":
|
||||
prefix = config["api_prefix"]
|
||||
else:
|
||||
prefix = config["pages_prefix"]
|
||||
|
||||
# Build tags
|
||||
tags = [f"{module_code}-{frontend}-{route_type}"]
|
||||
# Build tags - use custom tags if provided, otherwise default
|
||||
if custom_tags:
|
||||
tags = custom_tags
|
||||
else:
|
||||
tags = [f"{frontend}-{module_code}"]
|
||||
|
||||
route_info = RouteInfo(
|
||||
router=router,
|
||||
@@ -205,6 +239,8 @@ def _discover_routes_in_dir(
|
||||
module_code=module_code,
|
||||
route_type=route_type,
|
||||
frontend=frontend,
|
||||
priority=priority,
|
||||
custom_prefix=custom_prefix,
|
||||
)
|
||||
routes.append(route_info)
|
||||
|
||||
@@ -238,6 +274,36 @@ def get_admin_page_routes() -> list[RouteInfo]:
|
||||
return [r for r in discover_module_routes() if r.route_type == "pages" and r.frontend == "admin"]
|
||||
|
||||
|
||||
def get_admin_api_routes() -> list[RouteInfo]:
|
||||
"""
|
||||
Get admin API routes from modules, sorted by priority.
|
||||
|
||||
Returns routes sorted by priority (lower first, higher last).
|
||||
This ensures catch-all routes (priority 100+) are registered after
|
||||
specific routes.
|
||||
"""
|
||||
routes = [
|
||||
r for r in discover_module_routes()
|
||||
if r.route_type == "api" and r.frontend == "admin"
|
||||
]
|
||||
return sorted(routes, key=lambda r: r.priority)
|
||||
|
||||
|
||||
def get_vendor_api_routes() -> list[RouteInfo]:
|
||||
"""
|
||||
Get vendor API routes from modules, sorted by priority.
|
||||
|
||||
Returns routes sorted by priority (lower first, higher last).
|
||||
This ensures catch-all routes (priority 100+) are registered after
|
||||
specific routes.
|
||||
"""
|
||||
routes = [
|
||||
r for r in discover_module_routes()
|
||||
if r.route_type == "api" and r.frontend == "vendor"
|
||||
]
|
||||
return sorted(routes, key=lambda r: r.priority)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RouteInfo",
|
||||
"discover_module_routes",
|
||||
@@ -245,4 +311,6 @@ __all__ = [
|
||||
"get_page_routes",
|
||||
"get_vendor_page_routes",
|
||||
"get_admin_page_routes",
|
||||
"get_admin_api_routes",
|
||||
"get_vendor_api_routes",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user