# 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 stores - 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.modules.enums import FrontendType from app.modules.orders.schemas import ( BulkResolveRequest, BulkResolveResponse, IgnoreExceptionRequest, OrderItemExceptionListResponse, OrderItemExceptionResponse, OrderItemExceptionStats, ResolveExceptionRequest, ) from app.modules.orders.services.order_item_exception_service import ( order_item_exception_service, ) from app.modules.tenancy.schemas.auth import UserContext logger = logging.getLogger(__name__) admin_exceptions_router = APIRouter( prefix="/order-exceptions", tags=["Order Item Exceptions"], dependencies=[Depends(require_module_access("orders", FrontendType.ADMIN))], ) # ============================================================================ # Exception Listing and Stats # ============================================================================ @admin_exceptions_router.get("", response_model=OrderItemExceptionListResponse) def list_exceptions( store_id: int | None = Query(None, description="Filter by store"), 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, store_id=store_id, status=status, search=search, skip=skip, limit=limit, ) # Enrich with order and store 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 store name for cross-store view if order.store: item.store_name = order.store.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( store_id: int | None = Query(None, description="Filter by store"), 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, store_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, store_id: int = Query(..., description="Store 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, store_id=store_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, )