feat: add order item exception system for graceful product matching

Replaces the "fail on missing product" behavior with graceful handling:
- Orders import even when products aren't found by GTIN
- Unmatched items link to a per-vendor placeholder product
- Exceptions tracked in order_item_exceptions table for QC resolution
- Order confirmation blocked until exceptions are resolved
- Auto-matching when products are imported via catalog sync

New files:
- OrderItemException model and migration
- OrderItemExceptionService with CRUD and resolution logic
- Admin and vendor API endpoints for exception management
- Domain exceptions for error handling

Modified:
- OrderItem: added needs_product_match flag and exception relationship
- OrderService: graceful handling with placeholder products
- MarketplaceProductService: auto-match on product import
- Letzshop confirm endpoints: blocking check for unresolved exceptions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-20 13:11:47 +01:00
parent e0a0da85f8
commit d6d658dd85
19 changed files with 2215 additions and 16 deletions

View File

@@ -0,0 +1,179 @@
"""add_order_item_exceptions
Revision ID: d2e3f4a5b6c7
Revises: c1d2e3f4a5b6
Create Date: 2025-12-20
This migration adds the Order Item Exception system:
- Adds needs_product_match column to order_items table
- Creates order_item_exceptions table for tracking unmatched products
The exception system allows marketplace orders to be imported even when
products are not found by GTIN. Items are linked to a placeholder product
and exceptions are tracked for QC resolution.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = 'd2e3f4a5b6c7'
down_revision: Union[str, None] = 'c1d2e3f4a5b6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table."""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
"""Check if a table exists in the database."""
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(index_name: str, table_name: str) -> bool:
"""Check if an index exists on a table."""
bind = op.get_bind()
inspector = inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
except Exception:
return False
def upgrade() -> None:
# =========================================================================
# Step 1: Add needs_product_match column to order_items
# =========================================================================
if not column_exists('order_items', 'needs_product_match'):
op.add_column(
'order_items',
sa.Column(
'needs_product_match',
sa.Boolean(),
server_default='0',
nullable=False
)
)
if not index_exists('ix_order_items_needs_product_match', 'order_items'):
op.create_index(
'ix_order_items_needs_product_match',
'order_items',
['needs_product_match']
)
# =========================================================================
# Step 2: Create order_item_exceptions table
# =========================================================================
if not table_exists('order_item_exceptions'):
op.create_table(
'order_item_exceptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_item_id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('original_gtin', sa.String(length=50), nullable=True),
sa.Column('original_product_name', sa.String(length=500), nullable=True),
sa.Column('original_sku', sa.String(length=100), nullable=True),
sa.Column(
'exception_type',
sa.String(length=50),
nullable=False,
server_default='product_not_found'
),
sa.Column(
'status',
sa.String(length=50),
nullable=False,
server_default='pending'
),
sa.Column('resolved_product_id', sa.Integer(), nullable=True),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('resolved_by', sa.Integer(), nullable=True),
sa.Column('resolution_notes', sa.Text(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=False
),
sa.Column(
'updated_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=False
),
sa.ForeignKeyConstraint(
['order_item_id'],
['order_items.id'],
ondelete='CASCADE'
),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id']),
sa.ForeignKeyConstraint(['resolved_product_id'], ['products.id']),
sa.ForeignKeyConstraint(['resolved_by'], ['users.id']),
sa.PrimaryKeyConstraint('id')
)
# Create indexes
op.create_index(
'ix_order_item_exceptions_id',
'order_item_exceptions',
['id']
)
op.create_index(
'ix_order_item_exceptions_vendor_id',
'order_item_exceptions',
['vendor_id']
)
op.create_index(
'ix_order_item_exceptions_status',
'order_item_exceptions',
['status']
)
op.create_index(
'idx_exception_vendor_status',
'order_item_exceptions',
['vendor_id', 'status']
)
op.create_index(
'idx_exception_gtin',
'order_item_exceptions',
['vendor_id', 'original_gtin']
)
# Unique constraint on order_item_id (one exception per item)
op.create_index(
'uq_order_item_exception',
'order_item_exceptions',
['order_item_id'],
unique=True
)
def downgrade() -> None:
# Drop order_item_exceptions table
if table_exists('order_item_exceptions'):
op.drop_index('uq_order_item_exception', table_name='order_item_exceptions')
op.drop_index('idx_exception_gtin', table_name='order_item_exceptions')
op.drop_index('idx_exception_vendor_status', table_name='order_item_exceptions')
op.drop_index('ix_order_item_exceptions_status', table_name='order_item_exceptions')
op.drop_index('ix_order_item_exceptions_vendor_id', table_name='order_item_exceptions')
op.drop_index('ix_order_item_exceptions_id', table_name='order_item_exceptions')
op.drop_table('order_item_exceptions')
# Remove needs_product_match column from order_items
if column_exists('order_items', 'needs_product_match'):
if index_exists('ix_order_items_needs_product_match', 'order_items'):
op.drop_index('ix_order_items_needs_product_match', table_name='order_items')
op.drop_column('order_items', 'needs_product_match')

View File

@@ -38,6 +38,7 @@ from . import (
marketplace,
monitoring,
notifications,
order_item_exceptions,
orders,
products,
settings,
@@ -115,6 +116,11 @@ router.include_router(inventory.router, tags=["admin-inventory"])
# Include order management endpoints
router.include_router(orders.router, tags=["admin-orders"])
# Include order item exception management endpoints
router.include_router(
order_item_exceptions.router, tags=["admin-order-exceptions"]
)
# ============================================================================
# Marketplace & Imports

View File

@@ -16,7 +16,12 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException, ValidationException
from app.exceptions import (
OrderHasUnresolvedExceptionsException,
ResourceNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
@@ -783,6 +788,9 @@ def confirm_order(
Confirm all inventory units for a Letzshop order.
Sends confirmInventoryUnits mutation with isAvailable=true for all items.
Raises:
OrderHasUnresolvedExceptionsException: If order has unresolved product exceptions
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
@@ -792,6 +800,13 @@ def confirm_order(
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
# Check for unresolved exceptions (blocks confirmation)
unresolved_count = order_item_exception_service.get_unresolved_exception_count(
db, order_id
)
if unresolved_count > 0:
raise OrderHasUnresolvedExceptionsException(order_id, unresolved_count)
# Get inventory unit IDs from order items
items = order_service.get_order_items(order)
if not items:
@@ -933,6 +948,9 @@ def confirm_single_item(
Confirm a single inventory unit in an order.
Sends confirmInventoryUnits mutation with isAvailable=true for one item.
Raises:
OrderHasUnresolvedExceptionsException: If the specific item has an unresolved exception
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
@@ -942,6 +960,15 @@ def confirm_single_item(
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
# Check if this specific item has an unresolved exception
# Find the order item by external_item_id
item = next(
(i for i in order.items if i.external_item_id == item_id),
None
)
if item and item.needs_product_match:
raise OrderHasUnresolvedExceptionsException(order_id, 1)
try:
with creds_service.create_client(vendor_id) as client:
result = client.confirm_inventory_units([item_id])

View File

@@ -0,0 +1,249 @@
# app/api/v1/admin/order_item_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
from app.core.database import get_db
from app.services.order_item_exception_service import order_item_exception_service
from models.database.user import User
from models.schema.order_item_exception import (
BulkResolveRequest,
BulkResolveResponse,
IgnoreExceptionRequest,
OrderItemExceptionListResponse,
OrderItemExceptionResponse,
OrderItemExceptionStats,
ResolveExceptionRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/order-exceptions", tags=["Order Item Exceptions"])
# ============================================================================
# Exception Listing and Stats
# ============================================================================
@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: User = 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 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,
)
@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: User = 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
# ============================================================================
@router.get("/{exception_id}", response_model=OrderItemExceptionResponse)
def get_exception(
exception_id: int = Path(..., description="Exception ID"),
db: Session = Depends(get_db),
current_admin: User = 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
# ============================================================================
@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: User = 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
@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: User = 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
# ============================================================================
@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: User = 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,
)

View File

@@ -25,6 +25,7 @@ from . import (
marketplace,
media,
notifications,
order_item_exceptions,
orders,
payments,
products,
@@ -56,6 +57,7 @@ router.include_router(settings.router, tags=["vendor-settings"])
# Business operations (with prefixes: /products/*, /orders/*, etc.)
router.include_router(products.router, tags=["vendor-products"])
router.include_router(orders.router, tags=["vendor-orders"])
router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"])
router.include_router(customers.router, tags=["vendor-customers"])
router.include_router(team.router, tags=["vendor-team"])
router.include_router(inventory.router, tags=["vendor-inventory"])

View File

@@ -18,7 +18,12 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException, ValidationException
from app.exceptions import (
OrderHasUnresolvedExceptionsException,
ResourceNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
@@ -434,7 +439,12 @@ def confirm_order(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Confirm inventory units for a Letzshop order."""
"""
Confirm inventory units for a Letzshop order.
Raises:
OrderHasUnresolvedExceptionsException: If order has unresolved product exceptions
"""
vendor_id = current_user.token_vendor_id
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
@@ -444,6 +454,13 @@ def confirm_order(
except OrderNotFoundError:
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
# Check for unresolved exceptions (blocks confirmation)
unresolved_count = order_item_exception_service.get_unresolved_exception_count(
db, order_id
)
if unresolved_count > 0:
raise OrderHasUnresolvedExceptionsException(order_id, unresolved_count)
# Get inventory unit IDs from request or order
if confirm_request and confirm_request.inventory_unit_ids:
inventory_unit_ids = confirm_request.inventory_unit_ids

View File

@@ -0,0 +1,261 @@
# app/api/v1/vendor/order_item_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
from app.core.database import get_db
from app.services.order_item_exception_service import order_item_exception_service
from models.database.user import User
from models.schema.order_item_exception import (
BulkResolveRequest,
BulkResolveResponse,
IgnoreExceptionRequest,
OrderItemExceptionListResponse,
OrderItemExceptionResponse,
OrderItemExceptionStats,
ResolveExceptionRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/order-exceptions", tags=["Vendor Order Item Exceptions"])
# ============================================================================
# Exception Listing and Stats
# ============================================================================
@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: User = 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,
)
@router.get("/stats", response_model=OrderItemExceptionStats)
def get_vendor_exception_stats(
current_user: User = 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
# ============================================================================
@router.get("/{exception_id}", response_model=OrderItemExceptionResponse)
def get_vendor_exception(
exception_id: int = Path(..., description="Exception ID"),
current_user: User = 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
# ============================================================================
@router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse)
def resolve_vendor_exception(
exception_id: int = Path(..., description="Exception ID"),
request: ResolveExceptionRequest = ...,
current_user: User = 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
@router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse)
def ignore_vendor_exception(
exception_id: int = Path(..., description="Exception ID"),
request: IgnoreExceptionRequest = ...,
current_user: User = 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
# ============================================================================
@router.post("/bulk-resolve", response_model=BulkResolveResponse)
def bulk_resolve_vendor_exceptions(
request: BulkResolveRequest,
current_user: User = 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,
)

View File

@@ -135,6 +135,14 @@ from .order import (
OrderValidationException,
)
# Order item exception exceptions
from .order_item_exception import (
ExceptionAlreadyResolvedException,
InvalidProductForExceptionException,
OrderHasUnresolvedExceptionsException,
OrderItemExceptionNotFoundException,
)
# Product exceptions
from .product import (
CannotDeleteProductWithInventoryException,
@@ -308,6 +316,11 @@ __all__ = [
"OrderValidationException",
"InvalidOrderStatusException",
"OrderCannotBeCancelledException",
# Order item exception exceptions
"OrderItemExceptionNotFoundException",
"OrderHasUnresolvedExceptionsException",
"ExceptionAlreadyResolvedException",
"InvalidProductForExceptionException",
# Cart exceptions
"CartItemNotFoundException",
"EmptyCartException",

View File

@@ -0,0 +1,54 @@
# app/exceptions/order_item_exception.py
"""Order item exception specific exceptions."""
from .base import BusinessLogicException, ResourceNotFoundException
class OrderItemExceptionNotFoundException(ResourceNotFoundException):
"""Raised when an order item exception is not found."""
def __init__(self, exception_id: int | str):
super().__init__(
resource_type="OrderItemException",
identifier=str(exception_id),
error_code="ORDER_ITEM_EXCEPTION_NOT_FOUND",
)
class OrderHasUnresolvedExceptionsException(BusinessLogicException):
"""Raised when trying to confirm an order with unresolved exceptions."""
def __init__(self, order_id: int, unresolved_count: int):
super().__init__(
message=(
f"Order has {unresolved_count} unresolved product exception(s). "
f"Please resolve all exceptions before confirming the order."
),
error_code="ORDER_HAS_UNRESOLVED_EXCEPTIONS",
details={
"order_id": order_id,
"unresolved_count": unresolved_count,
},
)
class ExceptionAlreadyResolvedException(BusinessLogicException):
"""Raised when trying to resolve an already resolved exception."""
def __init__(self, exception_id: int):
super().__init__(
message=f"Exception {exception_id} has already been resolved",
error_code="EXCEPTION_ALREADY_RESOLVED",
details={"exception_id": exception_id},
)
class InvalidProductForExceptionException(BusinessLogicException):
"""Raised when the product provided for resolution is invalid."""
def __init__(self, product_id: int, reason: str):
super().__init__(
message=f"Cannot use product {product_id} for resolution: {reason}",
error_code="INVALID_PRODUCT_FOR_EXCEPTION",
details={"product_id": product_id, "reason": reason},
)

View File

@@ -865,11 +865,14 @@ class MarketplaceProductService:
marketplace_product_id=mp.id,
is_active=True,
is_featured=False,
# Copy GTIN for order matching
gtin=mp.gtin,
gtin_type=mp.gtin_type if hasattr(mp, "gtin_type") else None,
)
db.add(product)
copied += 1
details.append({"id": mp.id, "status": "copied"})
details.append({"id": mp.id, "status": "copied", "gtin": mp.gtin})
except Exception as e:
logger.error(f"Failed to copy product {mp.id}: {str(e)}")
@@ -878,15 +881,48 @@ class MarketplaceProductService:
db.flush()
# Auto-match pending order item exceptions
# Collect GTINs and their product IDs from newly copied products
from app.services.order_item_exception_service import (
order_item_exception_service,
)
gtin_to_product: dict[str, int] = {}
for detail in details:
if detail.get("status") == "copied" and detail.get("gtin"):
# Find the product we just created
product = (
db.query(Product)
.filter(
Product.vendor_id == vendor_id,
Product.gtin == detail["gtin"],
)
.first()
)
if product:
gtin_to_product[detail["gtin"]] = product.id
auto_matched = 0
if gtin_to_product:
auto_matched = order_item_exception_service.auto_match_batch(
db, vendor_id, gtin_to_product
)
if auto_matched:
logger.info(
f"Auto-matched {auto_matched} order item exceptions "
f"during product copy to vendor {vendor_id}"
)
logger.info(
f"Copied {copied} products to vendor {vendor.name} "
f"(skipped: {skipped}, failed: {failed})"
f"(skipped: {skipped}, failed: {failed}, auto_matched: {auto_matched})"
)
return {
"copied": copied,
"skipped": skipped,
"failed": failed,
"auto_matched": auto_matched,
"details": details if len(details) <= 100 else None,
}

View File

@@ -0,0 +1,629 @@
# 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
order_item.product_name = product.get_title("en") # Default to English
order_item.product_sku = product.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
order_item.product_name = 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
order_item.product_name = 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()

View File

@@ -27,10 +27,17 @@ from app.exceptions import (
OrderNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from models.database.customer import Customer
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation
from models.database.order import Order, OrderItem
from models.database.product import Product
from models.database.vendor import Vendor
# Placeholder product constants
PLACEHOLDER_GTIN = "0000000000000"
PLACEHOLDER_MARKETPLACE_ID = "PLACEHOLDER"
from models.schema.order import (
AddressSnapshot,
CustomerSnapshot,
@@ -71,6 +78,98 @@ class OrderService:
return order_number
# =========================================================================
# Placeholder Product Management
# =========================================================================
def _get_or_create_placeholder_product(
self,
db: Session,
vendor_id: int,
) -> Product:
"""
Get or create the vendor's placeholder product for unmatched items.
When a marketplace order contains a GTIN that doesn't match any product
in the vendor's catalog, we link the order item to this placeholder
and create an exception for resolution.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Placeholder Product for the vendor
"""
# Check for existing placeholder product for this vendor
placeholder = (
db.query(Product)
.filter(
and_(
Product.vendor_id == vendor_id,
Product.gtin == PLACEHOLDER_GTIN,
)
)
.first()
)
if placeholder:
return placeholder
# Get or create placeholder marketplace product (shared)
mp = (
db.query(MarketplaceProduct)
.filter(
MarketplaceProduct.marketplace_product_id == PLACEHOLDER_MARKETPLACE_ID
)
.first()
)
if not mp:
mp = MarketplaceProduct(
marketplace_product_id=PLACEHOLDER_MARKETPLACE_ID,
marketplace="internal",
vendor_name="system",
product_type_enum="physical",
is_active=False, # Not for sale
)
db.add(mp)
db.flush()
# Add translation for placeholder
translation = MarketplaceProductTranslation(
marketplace_product_id=mp.id,
language="en",
title="Unmatched Product (Pending Resolution)",
description=(
"This is a placeholder for products not found during order import. "
"Please resolve the exception to assign the correct product."
),
)
db.add(translation)
db.flush()
logger.info(
f"Created placeholder MarketplaceProduct {mp.id}"
)
# Create vendor-specific placeholder product
placeholder = Product(
vendor_id=vendor_id,
marketplace_product_id=mp.id,
gtin=PLACEHOLDER_GTIN,
gtin_type="placeholder",
is_active=False,
)
db.add(placeholder)
db.flush()
logger.info(
f"Created placeholder product {placeholder.id} for vendor {vendor_id}"
)
return placeholder
# =========================================================================
# Customer Management
# =========================================================================
@@ -405,17 +504,15 @@ class OrderService:
)
products_by_gtin = {p.gtin: p for p in products if p.gtin}
# Validate all products exist BEFORE creating anything
# Identify missing GTINs (graceful handling - no exception raised)
missing_gtins = gtins - set(products_by_gtin.keys())
placeholder = None
if missing_gtins:
missing_gtin = next(iter(missing_gtins))
logger.error(
f"Product not found for GTIN {missing_gtin} in vendor {vendor_id}. "
f"Order: {order_number}"
)
raise ValidationException(
f"Product not found for GTIN {missing_gtin}. "
f"Please ensure the product catalog is in sync."
# Get or create placeholder product for unmatched items
placeholder = self._get_or_create_placeholder_product(db, vendor_id)
logger.warning(
f"Order {order_number}: {len(missing_gtins)} product(s) not found. "
f"GTINs: {missing_gtins}. Using placeholder and creating exceptions."
)
# Parse address data
@@ -520,6 +617,7 @@ class OrderService:
db.flush()
# Create order items from inventory units
exceptions_created = 0
for unit in inventory_units:
variant = unit.get("variant", {}) or {}
product_info = variant.get("product", {}) or {}
@@ -529,10 +627,16 @@ class OrderService:
gtin = trade_id.get("number")
gtin_type = trade_id.get("parser")
# Get product from pre-validated map
# Get product from map, or use placeholder if not found
product = products_by_gtin.get(gtin)
needs_product_match = False
# Get product name
if not product:
# Use placeholder for unmatched items
product = placeholder
needs_product_match = True
# Get product name from marketplace data
product_name = (
product_name_dict.get("en")
or product_name_dict.get("fr")
@@ -564,12 +668,33 @@ class OrderService:
external_item_id=unit.get("id"),
external_variant_id=variant.get("id"),
item_state=item_state,
needs_product_match=needs_product_match,
)
db.add(order_item)
db.flush() # Get order_item.id for exception creation
# Create exception record for unmatched items
if needs_product_match:
order_item_exception_service.create_exception(
db=db,
order_item=order_item,
vendor_id=vendor_id,
original_gtin=gtin,
original_product_name=product_name,
original_sku=variant.get("sku"),
exception_type="product_not_found",
)
exceptions_created += 1
db.flush()
db.refresh(order)
if exceptions_created > 0:
logger.info(
f"Created {exceptions_created} order item exception(s) for "
f"order {order.order_number}"
)
logger.info(
f"Letzshop order {order.order_number} created for vendor {vendor_id}, "
f"status: {status}, items: {len(inventory_units)}"

View File

@@ -0,0 +1,264 @@
# Order Item Exception System
## Overview
The Order Item Exception system handles unmatched products during marketplace order imports. Instead of blocking imports when products cannot be found by GTIN, the system gracefully imports orders with placeholder products and creates exception records for QC resolution.
## Design Principles
1. **Graceful Import** - Orders are imported even when products aren't found
2. **Exception Tracking** - Unmatched items are tracked in `order_item_exceptions` table
3. **Resolution Workflow** - Admin/vendor can assign correct products
4. **Confirmation Blocking** - Orders with unresolved exceptions cannot be confirmed
5. **Auto-Match** - Exceptions auto-resolve when matching products are imported
## Database Schema
### order_item_exceptions Table
| Column | Type | Description |
|--------|------|-------------|
| id | Integer | Primary key |
| order_item_id | Integer | FK to order_items (unique) |
| vendor_id | Integer | FK to vendors (indexed) |
| original_gtin | String(50) | GTIN from marketplace |
| original_product_name | String(500) | Product name from marketplace |
| original_sku | String(100) | SKU from marketplace |
| exception_type | String(50) | product_not_found, gtin_mismatch, duplicate_gtin |
| status | String(50) | pending, resolved, ignored |
| resolved_product_id | Integer | FK to products (nullable) |
| resolved_at | DateTime | When resolved |
| resolved_by | Integer | FK to users |
| resolution_notes | Text | Optional notes |
| created_at | DateTime | Created timestamp |
| updated_at | DateTime | Updated timestamp |
### order_items Table (Modified)
Added column:
- `needs_product_match: Boolean (default False, indexed)`
### Placeholder Product
Per-vendor placeholder with:
- `gtin = "0000000000000"`
- `gtin_type = "placeholder"`
- `is_active = False`
## Workflow
```
Import Order from Marketplace
Query Products by GTIN
┌────┴────┐
│ │
Found Not Found
│ │
▼ ▼
Normal Create with placeholder
Item + Set needs_product_match=True
+ Create OrderItemException
QC Dashboard shows pending
┌─────┴─────┐
│ │
Resolve Ignore
(assign (with
product) reason)
│ │
▼ ▼
Update item Mark ignored
product_id (still blocks)
Order can now be confirmed
```
## API Endpoints
### Admin Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/admin/order-exceptions` | List all exceptions |
| GET | `/api/v1/admin/order-exceptions/stats` | Get exception statistics |
| GET | `/api/v1/admin/order-exceptions/{id}` | Get exception details |
| POST | `/api/v1/admin/order-exceptions/{id}/resolve` | Resolve with product |
| POST | `/api/v1/admin/order-exceptions/{id}/ignore` | Mark as ignored |
| POST | `/api/v1/admin/order-exceptions/bulk-resolve` | Bulk resolve by GTIN |
### Vendor Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/vendor/order-exceptions` | List vendor's exceptions |
| GET | `/api/v1/vendor/order-exceptions/stats` | Get vendor's stats |
| GET | `/api/v1/vendor/order-exceptions/{id}` | Get exception details |
| POST | `/api/v1/vendor/order-exceptions/{id}/resolve` | Resolve with product |
| POST | `/api/v1/vendor/order-exceptions/{id}/ignore` | Mark as ignored |
| POST | `/api/v1/vendor/order-exceptions/bulk-resolve` | Bulk resolve by GTIN |
## Exception Types
| Type | Description |
|------|-------------|
| `product_not_found` | GTIN not in vendor's product catalog |
| `gtin_mismatch` | GTIN format issue |
| `duplicate_gtin` | Multiple products with same GTIN |
## Exception Statuses
| Status | Description | Blocks Confirmation |
|--------|-------------|---------------------|
| `pending` | Awaiting resolution | Yes |
| `resolved` | Product assigned | No |
| `ignored` | Marked as ignored | Yes |
**Note:** Both `pending` and `ignored` statuses block order confirmation.
## Auto-Matching
When products are imported to the vendor catalog (via copy_to_vendor_catalog), the system automatically:
1. Collects GTINs of newly imported products
2. Finds pending exceptions with matching GTINs
3. Resolves them by assigning the new product
This happens automatically during:
- Single product import
- Bulk product import (marketplace sync)
## Integration Points
### Order Creation (`app/services/order_service.py`)
The `create_letzshop_order()` method:
1. Queries products by GTIN
2. For missing GTINs, creates placeholder product
3. Creates order items with `needs_product_match=True`
4. Creates exception records
### Order Confirmation
Confirmation endpoints check for unresolved exceptions:
- Admin: `app/api/v1/admin/letzshop.py`
- Vendor: `app/api/v1/vendor/letzshop.py`
Raises `OrderHasUnresolvedExceptionsException` if exceptions exist.
### Product Import (`app/services/marketplace_product_service.py`)
The `copy_to_vendor_catalog()` method:
1. Copies GTIN from MarketplaceProduct to Product
2. Calls auto-match service after products are created
3. Returns `auto_matched` count in response
## Files Created/Modified
### New Files
| File | Description |
|------|-------------|
| `models/database/order_item_exception.py` | Database model |
| `models/schema/order_item_exception.py` | Pydantic schemas |
| `app/services/order_item_exception_service.py` | Business logic |
| `app/exceptions/order_item_exception.py` | Domain exceptions |
| `app/api/v1/admin/order_item_exceptions.py` | Admin endpoints |
| `app/api/v1/vendor/order_item_exceptions.py` | Vendor endpoints |
| `alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py` | Migration |
### Modified Files
| File | Changes |
|------|---------|
| `models/database/order.py` | Added `needs_product_match`, exception relationship |
| `models/database/__init__.py` | Export OrderItemException |
| `models/schema/order.py` | Added exception info to OrderItemResponse |
| `app/services/order_service.py` | Graceful handling of missing products |
| `app/services/marketplace_product_service.py` | Auto-match on product import |
| `app/api/v1/admin/letzshop.py` | Confirmation blocking check |
| `app/api/v1/vendor/letzshop.py` | Confirmation blocking check |
| `app/api/v1/admin/__init__.py` | Register exception router |
| `app/api/v1/vendor/__init__.py` | Register exception router |
| `app/exceptions/__init__.py` | Export new exceptions |
## Response Examples
### List Exceptions
```json
{
"exceptions": [
{
"id": 1,
"order_item_id": 42,
"vendor_id": 1,
"original_gtin": "4006381333931",
"original_product_name": "Funko Pop! Marvel...",
"original_sku": "MH-FU-56757",
"exception_type": "product_not_found",
"status": "pending",
"order_number": "LS-1-R702236251",
"order_date": "2025-12-19T10:30:00Z",
"created_at": "2025-12-19T11:00:00Z"
}
],
"total": 15,
"skip": 0,
"limit": 50
}
```
### Exception Stats
```json
{
"pending": 15,
"resolved": 42,
"ignored": 3,
"total": 60,
"orders_with_exceptions": 8
}
```
### Resolve Exception
```json
POST /api/v1/admin/order-exceptions/1/resolve
{
"product_id": 123,
"notes": "Matched to correct product manually"
}
```
### Bulk Resolve
```json
POST /api/v1/admin/order-exceptions/bulk-resolve?vendor_id=1
{
"gtin": "4006381333931",
"product_id": 123,
"notes": "New product imported"
}
Response:
{
"resolved_count": 5,
"gtin": "4006381333931",
"product_id": 123
}
```
## Error Handling
| Exception | HTTP Status | When |
|-----------|-------------|------|
| `OrderItemExceptionNotFoundException` | 404 | Exception not found |
| `OrderHasUnresolvedExceptionsException` | 400 | Trying to confirm order with exceptions |
| `ExceptionAlreadyResolvedException` | 400 | Trying to resolve already resolved exception |
| `InvalidProductForExceptionException` | 400 | Invalid product (wrong vendor, inactive) |

View File

@@ -136,6 +136,7 @@ nav:
- Implementation Plans:
- Admin Inventory Management: implementation/inventory-admin-migration.md
- Letzshop Order Import: implementation/letzshop-order-import-improvements.md
- Order Item Exceptions: implementation/order-item-exceptions.md
- Unified Order View: implementation/unified-order-view.md
- Seed Scripts Audit: development/seed-scripts-audit.md
- Database Seeder:

View File

@@ -33,6 +33,7 @@ from .marketplace_product import (
)
from .marketplace_product_translation import MarketplaceProductTranslation
from .order import Order, OrderItem
from .order_item_exception import OrderItemException
from .product import Product
from .product_translation import ProductTranslation
from .test_run import TestCollection, TestResult, TestRun
@@ -89,6 +90,7 @@ __all__ = [
# Orders
"Order",
"OrderItem",
"OrderItemException",
# Letzshop Integration
"VendorLetzshopCredentials",
"LetzshopFulfillmentQueue",

View File

@@ -24,6 +24,10 @@ from sqlalchemy import (
String,
Text,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from models.database.order_item_exception import OrderItemException
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
@@ -201,9 +205,19 @@ class OrderItem(Base, TimestampMixin):
inventory_reserved = Column(Boolean, default=False)
inventory_fulfilled = Column(Boolean, default=False)
# === Exception Tracking ===
# True if product was not found by GTIN during import (linked to placeholder)
needs_product_match = Column(Boolean, default=False, index=True)
# === Relationships ===
order = relationship("Order", back_populates="items")
product = relationship("Product")
exception = relationship(
"OrderItemException",
back_populates="order_item",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id}, gtin='{self.gtin}')>"
@@ -222,3 +236,10 @@ class OrderItem(Base, TimestampMixin):
def is_declined(self) -> bool:
"""Check if item was declined (unavailable)."""
return self.item_state == "confirmed_unavailable"
@property
def has_unresolved_exception(self) -> bool:
"""Check if item has an unresolved exception blocking confirmation."""
if not self.exception:
return False
return self.exception.blocks_confirmation

View File

@@ -0,0 +1,117 @@
# models/database/order_item_exception.py
"""
Order Item Exception model for tracking unmatched products during marketplace imports.
When a marketplace order contains a GTIN that doesn't match any product in the
vendor's catalog, the order is still imported but the item is linked to a
placeholder product and an exception is recorded here for resolution.
"""
from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class OrderItemException(Base, TimestampMixin):
"""
Tracks unmatched order items requiring admin/vendor resolution.
When a marketplace order is imported and a product cannot be found by GTIN,
the order item is linked to a placeholder product and this exception record
is created. The order cannot be confirmed until all exceptions are resolved.
"""
__tablename__ = "order_item_exceptions"
id = Column(Integer, primary_key=True, index=True)
# Link to the order item (one-to-one)
order_item_id = Column(
Integer,
ForeignKey("order_items.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
# Vendor ID for efficient querying (denormalized from order)
vendor_id = Column(
Integer, ForeignKey("vendors.id"), nullable=False, index=True
)
# Original data from marketplace (preserved for matching)
original_gtin = Column(String(50), nullable=True, index=True)
original_product_name = Column(String(500), nullable=True)
original_sku = Column(String(100), nullable=True)
# Exception classification
# product_not_found: GTIN not in vendor catalog
# gtin_mismatch: GTIN format issue
# duplicate_gtin: Multiple products with same GTIN
exception_type = Column(
String(50), nullable=False, default="product_not_found"
)
# Resolution status
# pending: Awaiting resolution
# resolved: Product has been assigned
# ignored: Marked as ignored (still blocks confirmation)
status = Column(String(50), nullable=False, default="pending", index=True)
# Resolution details (populated when resolved)
resolved_product_id = Column(
Integer, ForeignKey("products.id"), nullable=True
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
resolved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
resolution_notes = Column(Text, nullable=True)
# Relationships
order_item = relationship("OrderItem", back_populates="exception")
vendor = relationship("Vendor")
resolved_product = relationship("Product")
resolver = relationship("User")
# Composite indexes for common queries
__table_args__ = (
Index("idx_exception_vendor_status", "vendor_id", "status"),
Index("idx_exception_gtin", "vendor_id", "original_gtin"),
)
def __repr__(self):
return (
f"<OrderItemException(id={self.id}, "
f"order_item_id={self.order_item_id}, "
f"gtin='{self.original_gtin}', "
f"status='{self.status}')>"
)
@property
def is_pending(self) -> bool:
"""Check if exception is pending resolution."""
return self.status == "pending"
@property
def is_resolved(self) -> bool:
"""Check if exception has been resolved."""
return self.status == "resolved"
@property
def is_ignored(self) -> bool:
"""Check if exception has been ignored."""
return self.status == "ignored"
@property
def blocks_confirmation(self) -> bool:
"""Check if this exception blocks order confirmation."""
# Both pending and ignored exceptions block confirmation
return self.status in ("pending", "ignored")

View File

@@ -57,6 +57,19 @@ class OrderItemCreate(BaseModel):
quantity: int = Field(..., ge=1)
class OrderItemExceptionBrief(BaseModel):
"""Brief exception info for embedding in order item responses."""
model_config = ConfigDict(from_attributes=True)
id: int
original_gtin: str | None
original_product_name: str | None
exception_type: str
status: str
resolved_product_id: int | None
class OrderItemResponse(BaseModel):
"""Schema for order item response."""
@@ -84,6 +97,10 @@ class OrderItemResponse(BaseModel):
inventory_reserved: bool
inventory_fulfilled: bool
# Exception tracking
needs_product_match: bool = False
exception: OrderItemExceptionBrief | None = None
created_at: datetime
updated_at: datetime
@@ -102,6 +119,13 @@ class OrderItemResponse(BaseModel):
"""Check if item was declined (unavailable)."""
return self.item_state == "confirmed_unavailable"
@property
def has_unresolved_exception(self) -> bool:
"""Check if item has an unresolved exception blocking confirmation."""
if not self.exception:
return False
return self.exception.status in ("pending", "ignored")
# ============================================================================
# Customer Snapshot Schemas

View File

@@ -0,0 +1,172 @@
# models/schema/order_item_exception.py
"""
Pydantic schemas for order item exception management.
Handles unmatched products during marketplace order imports.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ============================================================================
# Exception Response Schemas
# ============================================================================
class OrderItemExceptionResponse(BaseModel):
"""Schema for order item exception response."""
model_config = ConfigDict(from_attributes=True)
id: int
order_item_id: int
vendor_id: int
# Original data from marketplace
original_gtin: str | None
original_product_name: str | None
original_sku: str | None
# Exception classification
exception_type: str # product_not_found, gtin_mismatch, duplicate_gtin
# Resolution status
status: str # pending, resolved, ignored
# Resolution details
resolved_product_id: int | None
resolved_at: datetime | None
resolved_by: int | None
resolution_notes: str | None
# Timestamps
created_at: datetime
updated_at: datetime
# Nested order info (populated by service)
order_number: str | None = None
order_id: int | None = None
order_date: datetime | None = None
order_status: str | None = None
@property
def is_pending(self) -> bool:
"""Check if exception is pending resolution."""
return self.status == "pending"
@property
def is_resolved(self) -> bool:
"""Check if exception has been resolved."""
return self.status == "resolved"
@property
def is_ignored(self) -> bool:
"""Check if exception has been ignored."""
return self.status == "ignored"
class OrderItemExceptionBriefResponse(BaseModel):
"""Brief exception info for embedding in order item responses."""
model_config = ConfigDict(from_attributes=True)
id: int
original_gtin: str | None
original_product_name: str | None
exception_type: str
status: str
resolved_product_id: int | None
# ============================================================================
# List/Stats Response Schemas
# ============================================================================
class OrderItemExceptionListResponse(BaseModel):
"""Paginated list of exceptions."""
exceptions: list[OrderItemExceptionResponse]
total: int
skip: int
limit: int
class OrderItemExceptionStats(BaseModel):
"""Exception statistics for a vendor."""
pending: int = 0
resolved: int = 0
ignored: int = 0
total: int = 0
# Additional breakdown
orders_with_exceptions: int = 0
# ============================================================================
# Request Schemas
# ============================================================================
class ResolveExceptionRequest(BaseModel):
"""Request to resolve an exception by assigning a product."""
product_id: int = Field(..., description="Product ID to assign to this order item")
notes: str | None = Field(
None,
max_length=1000,
description="Optional notes about the resolution"
)
class IgnoreExceptionRequest(BaseModel):
"""Request to ignore an exception (still blocks confirmation)."""
notes: str = Field(
...,
min_length=1,
max_length=1000,
description="Reason for ignoring (required)"
)
class BulkResolveRequest(BaseModel):
"""Request to bulk resolve all pending exceptions for a GTIN."""
gtin: str = Field(
...,
min_length=1,
max_length=50,
description="GTIN to match pending exceptions"
)
product_id: int = Field(..., description="Product ID to assign")
notes: str | None = Field(
None,
max_length=1000,
description="Optional notes about the resolution"
)
class BulkResolveResponse(BaseModel):
"""Response from bulk resolve operation."""
resolved_count: int
gtin: str
product_id: int
# ============================================================================
# Auto-Match Response Schemas
# ============================================================================
class AutoMatchResult(BaseModel):
"""Result of auto-matching after product import."""
gtin: str
product_id: int
resolved_count: int
resolved_exception_ids: list[int]