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

@@ -28,6 +28,12 @@ from .marketplace_product import (
)
from .marketplace_product_translation import MarketplaceProductTranslation
from .order import Order, OrderItem
from .letzshop import (
LetzshopFulfillmentQueue,
LetzshopOrder,
LetzshopSyncLog,
VendorLetzshopCredentials,
)
from .product import Product
from .product_translation import ProductTranslation
from .user import User
@@ -82,4 +88,9 @@ __all__ = [
# Orders
"Order",
"OrderItem",
# Letzshop Integration
"VendorLetzshopCredentials",
"LetzshopOrder",
"LetzshopFulfillmentQueue",
"LetzshopSyncLog",
]

221
models/database/letzshop.py Normal file
View File

@@ -0,0 +1,221 @@
# models/database/letzshop.py
"""
Database models for Letzshop marketplace integration.
Provides models for:
- VendorLetzshopCredentials: Per-vendor API key storage (encrypted)
- LetzshopOrder: External order tracking and mapping
- LetzshopFulfillmentQueue: Outbound operation queue with retry
- LetzshopSyncLog: Audit trail for sync operations
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorLetzshopCredentials(Base, TimestampMixin):
"""
Per-vendor Letzshop API credentials.
Stores encrypted API keys and sync settings for each vendor's
Letzshop integration.
"""
__tablename__ = "vendor_letzshop_credentials"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
)
# Encrypted API credentials
api_key_encrypted = Column(Text, nullable=False)
api_endpoint = Column(String(255), default="https://letzshop.lu/graphql")
# Sync settings
auto_sync_enabled = Column(Boolean, default=False)
sync_interval_minutes = Column(Integer, default=15)
# Last sync status
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(50), nullable=True) # success, failed, partial
last_sync_error = Column(Text, nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="letzshop_credentials")
def __repr__(self):
return f"<VendorLetzshopCredentials(vendor_id={self.vendor_id}, auto_sync={self.auto_sync_enabled})>"
class LetzshopOrder(Base, TimestampMixin):
"""
Letzshop order tracking and mapping.
Stores imported orders from Letzshop with mapping to local Order model.
"""
__tablename__ = "letzshop_orders"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
# Letzshop identifiers
letzshop_order_id = Column(String(100), nullable=False, index=True)
letzshop_shipment_id = Column(String(100), nullable=True, index=True)
letzshop_order_number = Column(String(100), nullable=True)
# Local order mapping (if imported to local system)
local_order_id = Column(Integer, ForeignKey("orders.id"), nullable=True)
# Order state from Letzshop
letzshop_state = Column(String(50), nullable=True) # unconfirmed, confirmed, etc.
# Customer info from Letzshop
customer_email = Column(String(255), nullable=True)
customer_name = Column(String(255), nullable=True)
# Order totals from Letzshop
total_amount = Column(String(50), nullable=True) # Store as string to preserve format
currency = Column(String(10), default="EUR")
# Raw data storage (for debugging/auditing)
raw_order_data = Column(JSON, nullable=True)
# Inventory units (from Letzshop)
inventory_units = Column(JSON, nullable=True) # List of inventory unit IDs
# Sync status
sync_status = Column(
String(50), default="pending"
) # pending, imported, confirmed, rejected, shipped
last_synced_at = Column(DateTime(timezone=True), nullable=True)
sync_error = Column(Text, nullable=True)
# Fulfillment status
confirmed_at = Column(DateTime(timezone=True), nullable=True)
rejected_at = Column(DateTime(timezone=True), nullable=True)
tracking_set_at = Column(DateTime(timezone=True), nullable=True)
tracking_number = Column(String(100), nullable=True)
tracking_carrier = Column(String(100), nullable=True)
# Relationships
vendor = relationship("Vendor")
local_order = relationship("Order")
__table_args__ = (
Index("idx_letzshop_order_vendor", "vendor_id", "letzshop_order_id"),
Index("idx_letzshop_order_state", "vendor_id", "letzshop_state"),
Index("idx_letzshop_order_sync", "vendor_id", "sync_status"),
)
def __repr__(self):
return f"<LetzshopOrder(id={self.id}, letzshop_id='{self.letzshop_order_id}', state='{self.letzshop_state}')>"
class LetzshopFulfillmentQueue(Base, TimestampMixin):
"""
Queue for outbound fulfillment operations to Letzshop.
Supports retry logic for failed operations.
"""
__tablename__ = "letzshop_fulfillment_queue"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
letzshop_order_id = Column(
Integer, ForeignKey("letzshop_orders.id"), nullable=False
)
# Operation type
operation = Column(
String(50), nullable=False
) # confirm, reject, set_tracking, return
# Operation payload
payload = Column(JSON, nullable=False)
# Status and retry
status = Column(
String(50), default="pending"
) # pending, processing, completed, failed
attempts = Column(Integer, default=0)
max_attempts = Column(Integer, default=3)
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
next_retry_at = Column(DateTime(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Response from Letzshop
response_data = Column(JSON, nullable=True)
# Relationships
vendor = relationship("Vendor")
letzshop_order = relationship("LetzshopOrder")
__table_args__ = (
Index("idx_fulfillment_queue_status", "status", "vendor_id"),
Index("idx_fulfillment_queue_retry", "status", "next_retry_at"),
)
def __repr__(self):
return f"<LetzshopFulfillmentQueue(id={self.id}, operation='{self.operation}', status='{self.status}')>"
class LetzshopSyncLog(Base, TimestampMixin):
"""
Audit log for all Letzshop sync operations.
"""
__tablename__ = "letzshop_sync_logs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
# Operation details
operation_type = Column(
String(50), nullable=False
) # order_import, confirm_inventory, set_tracking, etc.
direction = Column(String(10), nullable=False) # inbound, outbound
# Status
status = Column(String(50), nullable=False) # success, failed, partial
# Details
records_processed = Column(Integer, default=0)
records_succeeded = Column(Integer, default=0)
records_failed = Column(Integer, default=0)
error_details = Column(JSON, nullable=True)
# Timestamps
started_at = Column(DateTime(timezone=True), nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True)
# Triggered by
triggered_by = Column(String(100), nullable=True) # user_id, scheduler, webhook
# Relationships
vendor = relationship("Vendor")
__table_args__ = (
Index("idx_sync_log_vendor_type", "vendor_id", "operation_type"),
Index("idx_sync_log_vendor_date", "vendor_id", "started_at"),
)
def __repr__(self):
return f"<LetzshopSyncLog(id={self.id}, type='{self.operation_type}', status='{self.status}')>"

View File

@@ -10,6 +10,7 @@ from sqlalchemy import (
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -29,6 +30,15 @@ class Order(Base, TimestampMixin):
order_number = Column(String, nullable=False, unique=True, index=True)
# Order channel/source
channel = Column(
String(50), default="direct", index=True
) # direct, letzshop, amazon, etc.
external_order_id = Column(
String(100), nullable=True, index=True
) # External order reference
external_channel_data = Column(JSON, nullable=True) # Channel-specific metadata
# Order status
status = Column(String, nullable=False, default="pending", index=True)
# pending, processing, shipped, delivered, cancelled, refunded

View File

@@ -96,6 +96,14 @@ class Vendor(Base, TimestampMixin):
"MarketplaceImportJob", back_populates="vendor"
) # Relationship with MarketplaceImportJob model for import jobs related to this vendor
# Letzshop integration credentials (one-to-one)
letzshop_credentials = relationship(
"VendorLetzshopCredentials",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
domains = relationship(
"VendorDomain",
back_populates="vendor",