Files
orion/alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py
Samir Boulahtit d6d658dd85 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>
2025-12-20 13:11:47 +01:00

180 lines
6.5 KiB
Python

"""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')