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>
180 lines
6.5 KiB
Python
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')
|