# app/services/order_item_exception_service.py """ Service for managing order item exceptions (unmatched products). This service handles: - Creating exceptions when products are not found during order import - Resolving exceptions by assigning products - Auto-matching when new products are imported - Querying and statistics for exceptions """ import logging from datetime import UTC, datetime from typing import Any from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session, joinedload from app.exceptions import ( ExceptionAlreadyResolvedException, InvalidProductForExceptionException, OrderItemExceptionNotFoundException, ProductNotFoundException, ) from models.database.order import Order, OrderItem from models.database.order_item_exception import OrderItemException from models.database.product import Product logger = logging.getLogger(__name__) class OrderItemExceptionService: """Service for order item exception CRUD and resolution workflow.""" # ========================================================================= # Exception Creation # ========================================================================= def create_exception( self, db: Session, order_item: OrderItem, vendor_id: int, original_gtin: str | None, original_product_name: str | None, original_sku: str | None, exception_type: str = "product_not_found", ) -> OrderItemException: """ Create an exception record for an unmatched order item. Args: db: Database session order_item: The order item that couldn't be matched vendor_id: Vendor ID (denormalized for efficient queries) original_gtin: Original GTIN from marketplace original_product_name: Original product name from marketplace original_sku: Original SKU from marketplace exception_type: Type of exception (product_not_found, gtin_mismatch, etc.) Returns: Created OrderItemException """ exception = OrderItemException( order_item_id=order_item.id, vendor_id=vendor_id, original_gtin=original_gtin, original_product_name=original_product_name, original_sku=original_sku, exception_type=exception_type, status="pending", ) db.add(exception) db.flush() logger.info( f"Created order item exception {exception.id} for order item " f"{order_item.id}, GTIN: {original_gtin}" ) return exception # ========================================================================= # Exception Retrieval # ========================================================================= def get_exception_by_id( self, db: Session, exception_id: int, vendor_id: int | None = None, ) -> OrderItemException: """ Get an exception by ID, optionally filtered by vendor. Args: db: Database session exception_id: Exception ID vendor_id: Optional vendor ID filter (for vendor-scoped access) Returns: OrderItemException Raises: OrderItemExceptionNotFoundException: If not found """ query = db.query(OrderItemException).filter( OrderItemException.id == exception_id ) if vendor_id is not None: query = query.filter(OrderItemException.vendor_id == vendor_id) exception = query.first() if not exception: raise OrderItemExceptionNotFoundException(exception_id) return exception def get_pending_exceptions( self, db: Session, vendor_id: int | None = None, status: str | None = None, search: str | None = None, skip: int = 0, limit: int = 50, ) -> tuple[list[OrderItemException], int]: """ Get exceptions with pagination and filtering. Args: db: Database session vendor_id: Optional vendor filter status: Optional status filter (pending, resolved, ignored) search: Optional search in GTIN, product name, or order number skip: Pagination offset limit: Pagination limit Returns: Tuple of (list of exceptions, total count) """ query = ( db.query(OrderItemException) .join(OrderItem) .join(Order) .options( joinedload(OrderItemException.order_item).joinedload(OrderItem.order) ) ) # Apply filters if vendor_id is not None: query = query.filter(OrderItemException.vendor_id == vendor_id) if status: query = query.filter(OrderItemException.status == status) if search: search_pattern = f"%{search}%" query = query.filter( or_( OrderItemException.original_gtin.ilike(search_pattern), OrderItemException.original_product_name.ilike(search_pattern), OrderItemException.original_sku.ilike(search_pattern), Order.order_number.ilike(search_pattern), ) ) # Get total count total = query.count() # Apply pagination and ordering exceptions = ( query.order_by(OrderItemException.created_at.desc()) .offset(skip) .limit(limit) .all() ) return exceptions, total def get_exceptions_for_order( self, db: Session, order_id: int, ) -> list[OrderItemException]: """ Get all exceptions for items in an order. Args: db: Database session order_id: Order ID Returns: List of exceptions for the order """ return ( db.query(OrderItemException) .join(OrderItem) .filter(OrderItem.order_id == order_id) .all() ) # ========================================================================= # Exception Statistics # ========================================================================= def get_exception_stats( self, db: Session, vendor_id: int | None = None, ) -> dict[str, int]: """ Get exception counts by status. Args: db: Database session vendor_id: Optional vendor filter Returns: Dict with pending, resolved, ignored, total counts """ query = db.query( OrderItemException.status, func.count(OrderItemException.id).label("count"), ) if vendor_id is not None: query = query.filter(OrderItemException.vendor_id == vendor_id) results = query.group_by(OrderItemException.status).all() stats = { "pending": 0, "resolved": 0, "ignored": 0, "total": 0, } for status, count in results: if status in stats: stats[status] = count stats["total"] += count # Count orders with pending exceptions orders_query = ( db.query(func.count(func.distinct(OrderItem.order_id))) .join(OrderItemException) .filter(OrderItemException.status == "pending") ) if vendor_id is not None: orders_query = orders_query.filter( OrderItemException.vendor_id == vendor_id ) stats["orders_with_exceptions"] = orders_query.scalar() or 0 return stats # ========================================================================= # Exception Resolution # ========================================================================= def resolve_exception( self, db: Session, exception_id: int, product_id: int, resolved_by: int, notes: str | None = None, vendor_id: int | None = None, ) -> OrderItemException: """ Resolve an exception by assigning a product. This updates: - The exception record (status, resolved_product_id, etc.) - The order item (product_id, needs_product_match) Args: db: Database session exception_id: Exception ID to resolve product_id: Product ID to assign resolved_by: User ID who resolved notes: Optional resolution notes vendor_id: Optional vendor filter (for scoped access) Returns: Updated OrderItemException Raises: OrderItemExceptionNotFoundException: If exception not found ExceptionAlreadyResolvedException: If already resolved InvalidProductForExceptionException: If product is invalid """ exception = self.get_exception_by_id(db, exception_id, vendor_id) if exception.status == "resolved": raise ExceptionAlreadyResolvedException(exception_id) # Validate product exists and belongs to vendor product = db.query(Product).filter(Product.id == product_id).first() if not product: raise ProductNotFoundException(product_id) if product.vendor_id != exception.vendor_id: raise InvalidProductForExceptionException( product_id, "Product belongs to a different vendor" ) if not product.is_active: raise InvalidProductForExceptionException( product_id, "Product is not active" ) # Update exception exception.status = "resolved" exception.resolved_product_id = product_id exception.resolved_at = datetime.now(UTC) exception.resolved_by = resolved_by exception.resolution_notes = notes # Update order item order_item = exception.order_item order_item.product_id = product_id order_item.needs_product_match = False # Update product snapshot on order item if product.marketplace_product: order_item.product_name = product.marketplace_product.get_title("en") order_item.product_sku = product.vendor_sku or order_item.product_sku db.flush() logger.info( f"Resolved exception {exception_id} with product {product_id} " f"by user {resolved_by}" ) return exception def ignore_exception( self, db: Session, exception_id: int, resolved_by: int, notes: str, vendor_id: int | None = None, ) -> OrderItemException: """ Mark an exception as ignored. Note: Ignored exceptions still block order confirmation. This is for tracking purposes (e.g., product will never be matched). Args: db: Database session exception_id: Exception ID resolved_by: User ID who ignored notes: Reason for ignoring (required) vendor_id: Optional vendor filter Returns: Updated OrderItemException """ exception = self.get_exception_by_id(db, exception_id, vendor_id) if exception.status == "resolved": raise ExceptionAlreadyResolvedException(exception_id) exception.status = "ignored" exception.resolved_at = datetime.now(UTC) exception.resolved_by = resolved_by exception.resolution_notes = notes db.flush() logger.info( f"Ignored exception {exception_id} by user {resolved_by}: {notes}" ) return exception # ========================================================================= # Auto-Matching # ========================================================================= def auto_match_by_gtin( self, db: Session, vendor_id: int, gtin: str, product_id: int, ) -> list[OrderItemException]: """ Auto-resolve pending exceptions matching a GTIN. Called after a product is imported with a GTIN. Args: db: Database session vendor_id: Vendor ID gtin: GTIN to match product_id: Product ID to assign Returns: List of resolved exceptions """ if not gtin: return [] # Find pending exceptions for this GTIN pending = ( db.query(OrderItemException) .filter( and_( OrderItemException.vendor_id == vendor_id, OrderItemException.original_gtin == gtin, OrderItemException.status == "pending", ) ) .all() ) if not pending: return [] # Get product for snapshot update product = db.query(Product).filter(Product.id == product_id).first() if not product: logger.warning(f"Product {product_id} not found for auto-match") return [] resolved = [] now = datetime.now(UTC) for exception in pending: exception.status = "resolved" exception.resolved_product_id = product_id exception.resolved_at = now exception.resolution_notes = "Auto-matched during product import" # Update order item order_item = exception.order_item order_item.product_id = product_id order_item.needs_product_match = False if product.marketplace_product: order_item.product_name = product.marketplace_product.get_title("en") resolved.append(exception) if resolved: db.flush() logger.info( f"Auto-matched {len(resolved)} exceptions for GTIN {gtin} " f"with product {product_id}" ) return resolved def auto_match_batch( self, db: Session, vendor_id: int, gtin_to_product: dict[str, int], ) -> int: """ Batch auto-match multiple GTINs after bulk import. Args: db: Database session vendor_id: Vendor ID gtin_to_product: Dict mapping GTIN to product ID Returns: Total number of resolved exceptions """ if not gtin_to_product: return 0 total_resolved = 0 for gtin, product_id in gtin_to_product.items(): resolved = self.auto_match_by_gtin(db, vendor_id, gtin, product_id) total_resolved += len(resolved) return total_resolved # ========================================================================= # Confirmation Checks # ========================================================================= def order_has_unresolved_exceptions( self, db: Session, order_id: int, ) -> bool: """ Check if order has any unresolved exceptions. An order cannot be confirmed if it has pending or ignored exceptions. Args: db: Database session order_id: Order ID Returns: True if order has unresolved exceptions """ count = ( db.query(func.count(OrderItemException.id)) .join(OrderItem) .filter( and_( OrderItem.order_id == order_id, OrderItemException.status.in_(["pending", "ignored"]), ) ) .scalar() ) return count > 0 def get_unresolved_exception_count( self, db: Session, order_id: int, ) -> int: """ Get count of unresolved exceptions for an order. Args: db: Database session order_id: Order ID Returns: Count of unresolved exceptions """ return ( db.query(func.count(OrderItemException.id)) .join(OrderItem) .filter( and_( OrderItem.order_id == order_id, OrderItemException.status.in_(["pending", "ignored"]), ) ) .scalar() ) or 0 # ========================================================================= # Bulk Operations # ========================================================================= def bulk_resolve_by_gtin( self, db: Session, vendor_id: int, gtin: str, product_id: int, resolved_by: int, notes: str | None = None, ) -> int: """ Bulk resolve all pending exceptions for a GTIN. Args: db: Database session vendor_id: Vendor ID gtin: GTIN to match product_id: Product ID to assign resolved_by: User ID who resolved notes: Optional notes Returns: Number of resolved exceptions """ # Validate product product = db.query(Product).filter(Product.id == product_id).first() if not product: raise ProductNotFoundException(product_id) if product.vendor_id != vendor_id: raise InvalidProductForExceptionException( product_id, "Product belongs to a different vendor" ) # Find and resolve all pending exceptions for this GTIN pending = ( db.query(OrderItemException) .filter( and_( OrderItemException.vendor_id == vendor_id, OrderItemException.original_gtin == gtin, OrderItemException.status == "pending", ) ) .all() ) now = datetime.now(UTC) resolution_notes = notes or f"Bulk resolved for GTIN {gtin}" for exception in pending: exception.status = "resolved" exception.resolved_product_id = product_id exception.resolved_at = now exception.resolved_by = resolved_by exception.resolution_notes = resolution_notes # Update order item order_item = exception.order_item order_item.product_id = product_id order_item.needs_product_match = False if product.marketplace_product: order_item.product_name = product.marketplace_product.get_title("en") db.flush() logger.info( f"Bulk resolved {len(pending)} exceptions for GTIN {gtin} " f"with product {product_id} by user {resolved_by}" ) return len(pending) # Global service instance order_item_exception_service = OrderItemExceptionService()