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:
179
alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py
Normal file
179
alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py
Normal 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')
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
249
app/api/v1/admin/order_item_exceptions.py
Normal file
249
app/api/v1/admin/order_item_exceptions.py
Normal 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,
|
||||
)
|
||||
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -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"])
|
||||
|
||||
21
app/api/v1/vendor/letzshop.py
vendored
21
app/api/v1/vendor/letzshop.py
vendored
@@ -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
|
||||
|
||||
261
app/api/v1/vendor/order_item_exceptions.py
vendored
Normal file
261
app/api/v1/vendor/order_item_exceptions.py
vendored
Normal 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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
54
app/exceptions/order_item_exception.py
Normal file
54
app/exceptions/order_item_exception.py
Normal 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},
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
629
app/services/order_item_exception_service.py
Normal file
629
app/services/order_item_exception_service.py
Normal 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()
|
||||
@@ -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)}"
|
||||
|
||||
264
docs/implementation/order-item-exceptions.md
Normal file
264
docs/implementation/order-item-exceptions.md
Normal 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) |
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
117
models/database/order_item_exception.py
Normal file
117
models/database/order_item_exception.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
172
models/schema/order_item_exception.py
Normal file
172
models/schema/order_item_exception.py
Normal 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]
|
||||
Reference in New Issue
Block a user