feat: add Letzshop bidirectional order integration
Add complete Letzshop marketplace integration with: - GraphQL client for order import and fulfillment operations - Encrypted credential storage per vendor (Fernet encryption) - Admin and vendor API endpoints for credentials management - Order import, confirmation, rejection, and tracking - Fulfillment queue and sync logging - Comprehensive documentation and test coverage New files: - app/services/letzshop/ - GraphQL client and services - app/utils/encryption.py - Fernet encryption utility - models/database/letzshop.py - Database models - models/schema/letzshop.py - Pydantic schemas - app/api/v1/admin/letzshop.py - Admin API endpoints - app/api/v1/vendor/letzshop.py - Vendor API endpoints - docs/guides/letzshop-order-integration.md - Documentation 🤖 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/987b4ecfa503_add_letzshop_integration_tables.py
Normal file
179
alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""add_letzshop_integration_tables
|
||||
|
||||
Revision ID: 987b4ecfa503
|
||||
Revises: 82ea1b4a3ccb
|
||||
Create Date: 2025-12-13
|
||||
|
||||
This migration adds:
|
||||
- vendor_letzshop_credentials: Per-vendor encrypted API key storage
|
||||
- letzshop_orders: Track imported orders with external IDs
|
||||
- letzshop_fulfillment_queue: Queue outbound operations with retry
|
||||
- letzshop_sync_logs: Audit trail for sync operations
|
||||
- Adds channel fields to orders table for multi-marketplace support
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '987b4ecfa503'
|
||||
down_revision: Union[str, None] = '82ea1b4a3ccb'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add channel fields to orders table
|
||||
op.add_column('orders', sa.Column('channel', sa.String(length=50), nullable=True, server_default='direct'))
|
||||
op.add_column('orders', sa.Column('external_order_id', sa.String(length=100), nullable=True))
|
||||
op.add_column('orders', sa.Column('external_channel_data', sa.JSON(), nullable=True))
|
||||
op.create_index(op.f('ix_orders_channel'), 'orders', ['channel'], unique=False)
|
||||
op.create_index(op.f('ix_orders_external_order_id'), 'orders', ['external_order_id'], unique=False)
|
||||
|
||||
# Create vendor_letzshop_credentials table
|
||||
op.create_table('vendor_letzshop_credentials',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=False),
|
||||
sa.Column('api_key_encrypted', sa.Text(), nullable=False),
|
||||
sa.Column('api_endpoint', sa.String(length=255), server_default='https://letzshop.lu/graphql', nullable=True),
|
||||
sa.Column('auto_sync_enabled', sa.Boolean(), server_default='0', nullable=True),
|
||||
sa.Column('sync_interval_minutes', sa.Integer(), server_default='15', nullable=True),
|
||||
sa.Column('last_sync_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_sync_status', sa.String(length=50), nullable=True),
|
||||
sa.Column('last_sync_error', 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(['vendor_id'], ['vendors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('vendor_id')
|
||||
)
|
||||
op.create_index(op.f('ix_vendor_letzshop_credentials_id'), 'vendor_letzshop_credentials', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_vendor_letzshop_credentials_vendor_id'), 'vendor_letzshop_credentials', ['vendor_id'], unique=True)
|
||||
|
||||
# Create letzshop_orders table
|
||||
op.create_table('letzshop_orders',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=False),
|
||||
sa.Column('letzshop_order_id', sa.String(length=100), nullable=False),
|
||||
sa.Column('letzshop_shipment_id', sa.String(length=100), nullable=True),
|
||||
sa.Column('letzshop_order_number', sa.String(length=100), nullable=True),
|
||||
sa.Column('local_order_id', sa.Integer(), nullable=True),
|
||||
sa.Column('letzshop_state', sa.String(length=50), nullable=True),
|
||||
sa.Column('customer_email', sa.String(length=255), nullable=True),
|
||||
sa.Column('customer_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('total_amount', sa.String(length=50), nullable=True),
|
||||
sa.Column('currency', sa.String(length=10), server_default='EUR', nullable=True),
|
||||
sa.Column('raw_order_data', sa.JSON(), nullable=True),
|
||||
sa.Column('inventory_units', sa.JSON(), nullable=True),
|
||||
sa.Column('sync_status', sa.String(length=50), server_default='pending', nullable=True),
|
||||
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('sync_error', sa.Text(), nullable=True),
|
||||
sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('rejected_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('tracking_set_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('tracking_number', sa.String(length=100), nullable=True),
|
||||
sa.Column('tracking_carrier', sa.String(length=100), 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(['local_order_id'], ['orders.id'], ),
|
||||
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_letzshop_orders_id'), 'letzshop_orders', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_letzshop_orders_letzshop_order_id'), 'letzshop_orders', ['letzshop_order_id'], unique=False)
|
||||
op.create_index(op.f('ix_letzshop_orders_letzshop_shipment_id'), 'letzshop_orders', ['letzshop_shipment_id'], unique=False)
|
||||
op.create_index(op.f('ix_letzshop_orders_vendor_id'), 'letzshop_orders', ['vendor_id'], unique=False)
|
||||
op.create_index('idx_letzshop_order_vendor', 'letzshop_orders', ['vendor_id', 'letzshop_order_id'], unique=False)
|
||||
op.create_index('idx_letzshop_order_state', 'letzshop_orders', ['vendor_id', 'letzshop_state'], unique=False)
|
||||
op.create_index('idx_letzshop_order_sync', 'letzshop_orders', ['vendor_id', 'sync_status'], unique=False)
|
||||
|
||||
# Create letzshop_fulfillment_queue table
|
||||
op.create_table('letzshop_fulfillment_queue',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=False),
|
||||
sa.Column('letzshop_order_id', sa.Integer(), nullable=False),
|
||||
sa.Column('operation', sa.String(length=50), nullable=False),
|
||||
sa.Column('payload', sa.JSON(), nullable=False),
|
||||
sa.Column('status', sa.String(length=50), server_default='pending', nullable=True),
|
||||
sa.Column('attempts', sa.Integer(), server_default='0', nullable=True),
|
||||
sa.Column('max_attempts', sa.Integer(), server_default='3', nullable=True),
|
||||
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('response_data', sa.JSON(), 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(['letzshop_order_id'], ['letzshop_orders.id'], ),
|
||||
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_letzshop_fulfillment_queue_id'), 'letzshop_fulfillment_queue', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_letzshop_fulfillment_queue_vendor_id'), 'letzshop_fulfillment_queue', ['vendor_id'], unique=False)
|
||||
op.create_index('idx_fulfillment_queue_status', 'letzshop_fulfillment_queue', ['status', 'vendor_id'], unique=False)
|
||||
op.create_index('idx_fulfillment_queue_retry', 'letzshop_fulfillment_queue', ['status', 'next_retry_at'], unique=False)
|
||||
|
||||
# Create letzshop_sync_logs table
|
||||
op.create_table('letzshop_sync_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=False),
|
||||
sa.Column('operation_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('direction', sa.String(length=10), nullable=False),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('records_processed', sa.Integer(), server_default='0', nullable=True),
|
||||
sa.Column('records_succeeded', sa.Integer(), server_default='0', nullable=True),
|
||||
sa.Column('records_failed', sa.Integer(), server_default='0', nullable=True),
|
||||
sa.Column('error_details', sa.JSON(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('duration_seconds', sa.Integer(), nullable=True),
|
||||
sa.Column('triggered_by', sa.String(length=100), 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(['vendor_id'], ['vendors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_letzshop_sync_logs_id'), 'letzshop_sync_logs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_letzshop_sync_logs_vendor_id'), 'letzshop_sync_logs', ['vendor_id'], unique=False)
|
||||
op.create_index('idx_sync_log_vendor_type', 'letzshop_sync_logs', ['vendor_id', 'operation_type'], unique=False)
|
||||
op.create_index('idx_sync_log_vendor_date', 'letzshop_sync_logs', ['vendor_id', 'started_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop letzshop_sync_logs table
|
||||
op.drop_index('idx_sync_log_vendor_date', table_name='letzshop_sync_logs')
|
||||
op.drop_index('idx_sync_log_vendor_type', table_name='letzshop_sync_logs')
|
||||
op.drop_index(op.f('ix_letzshop_sync_logs_vendor_id'), table_name='letzshop_sync_logs')
|
||||
op.drop_index(op.f('ix_letzshop_sync_logs_id'), table_name='letzshop_sync_logs')
|
||||
op.drop_table('letzshop_sync_logs')
|
||||
|
||||
# Drop letzshop_fulfillment_queue table
|
||||
op.drop_index('idx_fulfillment_queue_retry', table_name='letzshop_fulfillment_queue')
|
||||
op.drop_index('idx_fulfillment_queue_status', table_name='letzshop_fulfillment_queue')
|
||||
op.drop_index(op.f('ix_letzshop_fulfillment_queue_vendor_id'), table_name='letzshop_fulfillment_queue')
|
||||
op.drop_index(op.f('ix_letzshop_fulfillment_queue_id'), table_name='letzshop_fulfillment_queue')
|
||||
op.drop_table('letzshop_fulfillment_queue')
|
||||
|
||||
# Drop letzshop_orders table
|
||||
op.drop_index('idx_letzshop_order_sync', table_name='letzshop_orders')
|
||||
op.drop_index('idx_letzshop_order_state', table_name='letzshop_orders')
|
||||
op.drop_index('idx_letzshop_order_vendor', table_name='letzshop_orders')
|
||||
op.drop_index(op.f('ix_letzshop_orders_vendor_id'), table_name='letzshop_orders')
|
||||
op.drop_index(op.f('ix_letzshop_orders_letzshop_shipment_id'), table_name='letzshop_orders')
|
||||
op.drop_index(op.f('ix_letzshop_orders_letzshop_order_id'), table_name='letzshop_orders')
|
||||
op.drop_index(op.f('ix_letzshop_orders_id'), table_name='letzshop_orders')
|
||||
op.drop_table('letzshop_orders')
|
||||
|
||||
# Drop vendor_letzshop_credentials table
|
||||
op.drop_index(op.f('ix_vendor_letzshop_credentials_vendor_id'), table_name='vendor_letzshop_credentials')
|
||||
op.drop_index(op.f('ix_vendor_letzshop_credentials_id'), table_name='vendor_letzshop_credentials')
|
||||
op.drop_table('vendor_letzshop_credentials')
|
||||
|
||||
# Drop channel fields from orders table
|
||||
op.drop_index(op.f('ix_orders_external_order_id'), table_name='orders')
|
||||
op.drop_index(op.f('ix_orders_channel'), table_name='orders')
|
||||
op.drop_column('orders', 'external_channel_data')
|
||||
op.drop_column('orders', 'external_order_id')
|
||||
op.drop_column('orders', 'channel')
|
||||
Reference in New Issue
Block a user