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:
2025-12-13 12:19:54 +01:00
parent 837b1f93f4
commit 448f01f82b
20 changed files with 5251 additions and 0 deletions

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