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