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

@@ -32,6 +32,7 @@ from . import (
companies,
content_pages,
dashboard,
letzshop,
logs,
marketplace,
monitoring,
@@ -114,6 +115,9 @@ router.include_router(vendor_products.router, tags=["admin-vendor-products"])
# Include marketplace monitoring endpoints
router.include_router(marketplace.router, tags=["admin-marketplace"])
# Include Letzshop integration endpoints
router.include_router(letzshop.router, tags=["admin-letzshop"])
# ============================================================================
# Platform Administration

View File

@@ -0,0 +1,470 @@
# app/api/v1/admin/letzshop.py
"""
Admin API endpoints for Letzshop marketplace integration.
Provides admin-level management of:
- Per-vendor Letzshop credentials
- Connection testing
- Sync triggers and status
- Order overview
"""
import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException, ValidationException
from app.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
LetzshopCredentialsService,
LetzshopOrderService,
VendorNotFoundError,
)
from models.database.user import User
from models.schema.letzshop import (
LetzshopConnectionTestRequest,
LetzshopConnectionTestResponse,
LetzshopCredentialsCreate,
LetzshopCredentialsResponse,
LetzshopCredentialsUpdate,
LetzshopOrderListResponse,
LetzshopOrderResponse,
LetzshopSuccessResponse,
LetzshopSyncTriggerRequest,
LetzshopSyncTriggerResponse,
LetzshopVendorListResponse,
LetzshopVendorOverview,
)
router = APIRouter(prefix="/letzshop")
logger = logging.getLogger(__name__)
# ============================================================================
# Helper Functions
# ============================================================================
def get_order_service(db: Session) -> LetzshopOrderService:
"""Get order service instance."""
return LetzshopOrderService(db)
def get_credentials_service(db: Session) -> LetzshopCredentialsService:
"""Get credentials service instance."""
return LetzshopCredentialsService(db)
# ============================================================================
# Vendor Overview
# ============================================================================
@router.get("/vendors", response_model=LetzshopVendorListResponse)
def list_vendors_letzshop_status(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
configured_only: bool = Query(False, description="Only show vendors with Letzshop configured"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
List all vendors with their Letzshop integration status.
Shows which vendors have Letzshop configured, sync status, and pending orders.
"""
order_service = get_order_service(db)
vendor_overviews, total = order_service.list_vendors_with_letzshop_status(
skip=skip,
limit=limit,
configured_only=configured_only,
)
return LetzshopVendorListResponse(
vendors=[LetzshopVendorOverview(**v) for v in vendor_overviews],
total=total,
skip=skip,
limit=limit,
)
# ============================================================================
# Credentials Management
# ============================================================================
@router.get(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def get_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get Letzshop credentials for a vendor (API key is masked)."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
try:
credentials = creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ResourceNotFoundException(
"LetzshopCredentials", str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}"
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.post(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def create_or_update_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
credentials_data: LetzshopCredentialsCreate = ...,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Create or update Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
credentials = creds_service.upsert_credentials(
vendor_id=vendor_id,
api_key=credentials_data.api_key,
api_endpoint=credentials_data.api_endpoint,
auto_sync_enabled=credentials_data.auto_sync_enabled,
sync_interval_minutes=credentials_data.sync_interval_minutes,
)
logger.info(
f"Admin {current_admin.email} updated Letzshop credentials for vendor {vendor.name}"
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.patch(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def update_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
credentials_data: LetzshopCredentialsUpdate = ...,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Partially update Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
try:
credentials = creds_service.update_credentials(
vendor_id=vendor_id,
api_key=credentials_data.api_key,
api_endpoint=credentials_data.api_endpoint,
auto_sync_enabled=credentials_data.auto_sync_enabled,
sync_interval_minutes=credentials_data.sync_interval_minutes,
)
except CredentialsNotFoundError:
raise ResourceNotFoundException(
"LetzshopCredentials", str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}"
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.delete(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopSuccessResponse,
)
def delete_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Delete Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
deleted = creds_service.delete_credentials(vendor_id)
if not deleted:
raise ResourceNotFoundException(
"LetzshopCredentials", str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}"
)
logger.info(
f"Admin {current_admin.email} deleted Letzshop credentials for vendor {vendor.name}"
)
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
# ============================================================================
# Connection Testing
# ============================================================================
@router.post(
"/vendors/{vendor_id}/test",
response_model=LetzshopConnectionTestResponse,
)
def test_vendor_connection(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Test the Letzshop connection for a vendor using stored credentials."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
success, response_time_ms, error = creds_service.test_connection(vendor_id)
return LetzshopConnectionTestResponse(
success=success,
message="Connection successful" if success else "Connection failed",
response_time_ms=response_time_ms,
error_details=error,
)
@router.post("/test", response_model=LetzshopConnectionTestResponse)
def test_api_key(
test_request: LetzshopConnectionTestRequest,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Test a Letzshop API key without saving it."""
creds_service = get_credentials_service(db)
success, response_time_ms, error = creds_service.test_api_key(
api_key=test_request.api_key,
api_endpoint=test_request.api_endpoint,
)
return LetzshopConnectionTestResponse(
success=success,
message="Connection successful" if success else "Connection failed",
response_time_ms=response_time_ms,
error_details=error,
)
# ============================================================================
# Order Management
# ============================================================================
@router.get(
"/vendors/{vendor_id}/orders",
response_model=LetzshopOrderListResponse,
)
def list_vendor_letzshop_orders(
vendor_id: int = Path(..., description="Vendor ID"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
sync_status: str | None = Query(None, description="Filter by sync status"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""List Letzshop orders for a vendor."""
order_service = get_order_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
sync_status=sync_status,
)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
letzshop_order_id=order.letzshop_order_id,
letzshop_shipment_id=order.letzshop_shipment_id,
letzshop_order_number=order.letzshop_order_number,
letzshop_state=order.letzshop_state,
customer_email=order.customer_email,
customer_name=order.customer_name,
total_amount=order.total_amount,
currency=order.currency,
local_order_id=order.local_order_id,
sync_status=order.sync_status,
last_synced_at=order.last_synced_at,
sync_error=order.sync_error,
confirmed_at=order.confirmed_at,
rejected_at=order.rejected_at,
tracking_set_at=order.tracking_set_at,
tracking_number=order.tracking_number,
tracking_carrier=order.tracking_carrier,
inventory_units=order.inventory_units,
created_at=order.created_at,
updated_at=order.updated_at,
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
)
@router.post(
"/vendors/{vendor_id}/sync",
response_model=LetzshopSyncTriggerResponse,
)
def trigger_vendor_sync(
vendor_id: int = Path(..., description="Vendor ID"),
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Trigger a sync operation for a vendor.
This imports new orders from Letzshop.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor.name}"
)
# Import orders using the client
try:
with creds_service.create_client(vendor_id) as client:
shipments = client.get_unconfirmed_shipments()
orders_imported = 0
orders_updated = 0
errors = []
for shipment in shipments:
try:
# Check if order already exists
existing = order_service.get_order_by_shipment_id(
vendor_id, shipment["id"]
)
if existing:
# Update existing order
order_service.update_order_from_shipment(existing, shipment)
orders_updated += 1
else:
# Create new order
order_service.create_order(vendor_id, shipment)
orders_imported += 1
except Exception as e:
errors.append(f"Error processing shipment {shipment.get('id')}: {e}")
db.commit()
# Update sync status
creds_service.update_sync_status(
vendor_id,
"success" if not errors else "partial",
"; ".join(errors) if errors else None,
)
return LetzshopSyncTriggerResponse(
success=True,
message=f"Sync completed: {orders_imported} imported, {orders_updated} updated",
orders_imported=orders_imported,
orders_updated=orders_updated,
errors=errors,
)
except LetzshopClientError as e:
creds_service.update_sync_status(vendor_id, "failed", str(e))
return LetzshopSyncTriggerResponse(
success=False,
message=f"Sync failed: {e}",
errors=[str(e)],
)

View File

@@ -21,6 +21,7 @@ from . import (
dashboard,
info,
inventory,
letzshop,
marketplace,
media,
notifications,
@@ -59,6 +60,7 @@ router.include_router(customers.router, tags=["vendor-customers"])
router.include_router(team.router, tags=["vendor-team"])
router.include_router(inventory.router, tags=["vendor-inventory"])
router.include_router(marketplace.router, tags=["vendor-marketplace"])
router.include_router(letzshop.router, tags=["vendor-letzshop"])
# Services (with prefixes: /payments/*, /media/*, etc.)
router.include_router(payments.router, tags=["vendor-payments"])

689
app/api/v1/vendor/letzshop.py vendored Normal file
View File

@@ -0,0 +1,689 @@
# app/api/v1/vendor/letzshop.py
"""
Vendor API endpoints for Letzshop marketplace integration.
Provides vendor-level management of:
- Letzshop credentials
- Connection testing
- Order import and sync
- Fulfillment operations (confirm, reject, tracking)
Vendor Context: Uses token_vendor_id from JWT token.
"""
import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException, ValidationException
from app.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
LetzshopCredentialsService,
LetzshopOrderService,
OrderNotFoundError,
)
from models.database.user import User
from models.schema.letzshop import (
FulfillmentConfirmRequest,
FulfillmentOperationResponse,
FulfillmentQueueItemResponse,
FulfillmentQueueListResponse,
FulfillmentRejectRequest,
FulfillmentTrackingRequest,
LetzshopConnectionTestRequest,
LetzshopConnectionTestResponse,
LetzshopCredentialsCreate,
LetzshopCredentialsResponse,
LetzshopCredentialsStatus,
LetzshopCredentialsUpdate,
LetzshopOrderDetailResponse,
LetzshopOrderListResponse,
LetzshopOrderResponse,
LetzshopSuccessResponse,
LetzshopSyncLogListResponse,
LetzshopSyncLogResponse,
LetzshopSyncTriggerRequest,
LetzshopSyncTriggerResponse,
)
router = APIRouter(prefix="/letzshop")
logger = logging.getLogger(__name__)
# ============================================================================
# Helper Functions
# ============================================================================
def get_order_service(db: Session) -> LetzshopOrderService:
"""Get order service instance."""
return LetzshopOrderService(db)
def get_credentials_service(db: Session) -> LetzshopCredentialsService:
"""Get credentials service instance."""
return LetzshopCredentialsService(db)
# ============================================================================
# Status & Configuration
# ============================================================================
@router.get("/status", response_model=LetzshopCredentialsStatus)
def get_letzshop_status(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get Letzshop integration status for the current vendor."""
creds_service = get_credentials_service(db)
status = creds_service.get_status(current_user.token_vendor_id)
return LetzshopCredentialsStatus(**status)
@router.get("/credentials", response_model=LetzshopCredentialsResponse)
def get_credentials(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get Letzshop credentials for the current vendor (API key is masked)."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_vendor_id
try:
credentials = creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ResourceNotFoundException("LetzshopCredentials", str(vendor_id))
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.post("/credentials", response_model=LetzshopCredentialsResponse)
def save_credentials(
credentials_data: LetzshopCredentialsCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create or update Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_vendor_id
credentials = creds_service.upsert_credentials(
vendor_id=vendor_id,
api_key=credentials_data.api_key,
api_endpoint=credentials_data.api_endpoint,
auto_sync_enabled=credentials_data.auto_sync_enabled,
sync_interval_minutes=credentials_data.sync_interval_minutes,
)
logger.info(f"Vendor user {current_user.email} updated Letzshop credentials")
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.patch("/credentials", response_model=LetzshopCredentialsResponse)
def update_credentials(
credentials_data: LetzshopCredentialsUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Partially update Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_vendor_id
try:
credentials = creds_service.update_credentials(
vendor_id=vendor_id,
api_key=credentials_data.api_key,
api_endpoint=credentials_data.api_endpoint,
auto_sync_enabled=credentials_data.auto_sync_enabled,
sync_interval_minutes=credentials_data.sync_interval_minutes,
)
except CredentialsNotFoundError:
raise ResourceNotFoundException("LetzshopCredentials", str(vendor_id))
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.delete("/credentials", response_model=LetzshopSuccessResponse)
def delete_credentials(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Delete Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
deleted = creds_service.delete_credentials(current_user.token_vendor_id)
if not deleted:
raise ResourceNotFoundException(
"LetzshopCredentials", str(current_user.token_vendor_id)
)
logger.info(f"Vendor user {current_user.email} deleted Letzshop credentials")
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
# ============================================================================
# Connection Testing
# ============================================================================
@router.post("/test", response_model=LetzshopConnectionTestResponse)
def test_connection(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Test the Letzshop connection using stored credentials."""
creds_service = get_credentials_service(db)
success, response_time_ms, error = creds_service.test_connection(
current_user.token_vendor_id
)
return LetzshopConnectionTestResponse(
success=success,
message="Connection successful" if success else "Connection failed",
response_time_ms=response_time_ms,
error_details=error,
)
@router.post("/test-key", response_model=LetzshopConnectionTestResponse)
def test_api_key(
test_request: LetzshopConnectionTestRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Test a Letzshop API key without saving it."""
creds_service = get_credentials_service(db)
success, response_time_ms, error = creds_service.test_api_key(
api_key=test_request.api_key,
api_endpoint=test_request.api_endpoint,
)
return LetzshopConnectionTestResponse(
success=success,
message="Connection successful" if success else "Connection failed",
response_time_ms=response_time_ms,
error_details=error,
)
# ============================================================================
# Order Management
# ============================================================================
@router.get("/orders", response_model=LetzshopOrderListResponse)
def list_orders(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
sync_status: str | None = Query(None, description="Filter by sync status"),
letzshop_state: str | None = Query(None, description="Filter by Letzshop state"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List Letzshop orders for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
sync_status=sync_status,
letzshop_state=letzshop_state,
)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
letzshop_order_id=order.letzshop_order_id,
letzshop_shipment_id=order.letzshop_shipment_id,
letzshop_order_number=order.letzshop_order_number,
letzshop_state=order.letzshop_state,
customer_email=order.customer_email,
customer_name=order.customer_name,
total_amount=order.total_amount,
currency=order.currency,
local_order_id=order.local_order_id,
sync_status=order.sync_status,
last_synced_at=order.last_synced_at,
sync_error=order.sync_error,
confirmed_at=order.confirmed_at,
rejected_at=order.rejected_at,
tracking_set_at=order.tracking_set_at,
tracking_number=order.tracking_number,
tracking_carrier=order.tracking_carrier,
inventory_units=order.inventory_units,
created_at=order.created_at,
updated_at=order.updated_at,
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
)
@router.get("/orders/{order_id}", response_model=LetzshopOrderDetailResponse)
def get_order(
order_id: int = Path(..., description="Order ID"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get a specific Letzshop order with full details."""
order_service = get_order_service(db)
try:
order = order_service.get_order_or_raise(
current_user.token_vendor_id, order_id
)
except OrderNotFoundError:
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
return LetzshopOrderDetailResponse(
id=order.id,
vendor_id=order.vendor_id,
letzshop_order_id=order.letzshop_order_id,
letzshop_shipment_id=order.letzshop_shipment_id,
letzshop_order_number=order.letzshop_order_number,
letzshop_state=order.letzshop_state,
customer_email=order.customer_email,
customer_name=order.customer_name,
total_amount=order.total_amount,
currency=order.currency,
local_order_id=order.local_order_id,
sync_status=order.sync_status,
last_synced_at=order.last_synced_at,
sync_error=order.sync_error,
confirmed_at=order.confirmed_at,
rejected_at=order.rejected_at,
tracking_set_at=order.tracking_set_at,
tracking_number=order.tracking_number,
tracking_carrier=order.tracking_carrier,
inventory_units=order.inventory_units,
raw_order_data=order.raw_order_data,
created_at=order.created_at,
updated_at=order.updated_at,
)
@router.post("/orders/import", response_model=LetzshopSyncTriggerResponse)
def import_orders(
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Import new orders from Letzshop."""
vendor_id = current_user.token_vendor_id
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException("Letzshop credentials not configured")
# Import orders
try:
with creds_service.create_client(vendor_id) as client:
shipments = client.get_unconfirmed_shipments()
orders_imported = 0
orders_updated = 0
errors = []
for shipment in shipments:
try:
existing = order_service.get_order_by_shipment_id(
vendor_id, shipment["id"]
)
if existing:
order_service.update_order_from_shipment(existing, shipment)
orders_updated += 1
else:
order_service.create_order(vendor_id, shipment)
orders_imported += 1
except Exception as e:
errors.append(f"Error processing shipment {shipment.get('id')}: {e}")
db.commit()
creds_service.update_sync_status(
vendor_id,
"success" if not errors else "partial",
"; ".join(errors) if errors else None,
)
return LetzshopSyncTriggerResponse(
success=True,
message=f"Import completed: {orders_imported} imported, {orders_updated} updated",
orders_imported=orders_imported,
orders_updated=orders_updated,
errors=errors,
)
except LetzshopClientError as e:
creds_service.update_sync_status(vendor_id, "failed", str(e))
return LetzshopSyncTriggerResponse(
success=False,
message=f"Import failed: {e}",
errors=[str(e)],
)
# ============================================================================
# Fulfillment Operations
# ============================================================================
@router.post("/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse)
def confirm_order(
order_id: int = Path(..., description="Order ID"),
confirm_request: FulfillmentConfirmRequest | None = None,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Confirm inventory units for a Letzshop order."""
vendor_id = current_user.token_vendor_id
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
# Get inventory unit IDs from request or order
if confirm_request and confirm_request.inventory_unit_ids:
inventory_unit_ids = confirm_request.inventory_unit_ids
elif order.inventory_units:
inventory_unit_ids = [u["id"] for u in order.inventory_units]
else:
raise ValidationException("No inventory units to confirm")
try:
with creds_service.create_client(vendor_id) as client:
result = client.confirm_inventory_units(inventory_unit_ids)
# Check for errors
if result.get("errors"):
error_messages = [
f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["errors"]
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be confirmed",
errors=error_messages,
)
# Update order status
order_service.mark_order_confirmed(order)
db.commit()
return FulfillmentOperationResponse(
success=True,
message=f"Confirmed {len(inventory_unit_ids)} inventory units",
confirmed_units=[
u.get("id") for u in result.get("inventoryUnits", [])
],
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post("/orders/{order_id}/reject", response_model=FulfillmentOperationResponse)
def reject_order(
order_id: int = Path(..., description="Order ID"),
reject_request: FulfillmentRejectRequest | None = None,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Reject inventory units for a Letzshop order."""
vendor_id = current_user.token_vendor_id
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
# Get inventory unit IDs from request or order
if reject_request and reject_request.inventory_unit_ids:
inventory_unit_ids = reject_request.inventory_unit_ids
elif order.inventory_units:
inventory_unit_ids = [u["id"] for u in order.inventory_units]
else:
raise ValidationException("No inventory units to reject")
try:
with creds_service.create_client(vendor_id) as client:
result = client.reject_inventory_units(inventory_unit_ids)
if result.get("errors"):
error_messages = [
f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["errors"]
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be rejected",
errors=error_messages,
)
order_service.mark_order_rejected(order)
db.commit()
return FulfillmentOperationResponse(
success=True,
message=f"Rejected {len(inventory_unit_ids)} inventory units",
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post("/orders/{order_id}/tracking", response_model=FulfillmentOperationResponse)
def set_order_tracking(
order_id: int = Path(..., description="Order ID"),
tracking_request: FulfillmentTrackingRequest = ...,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Set tracking information for a Letzshop order."""
vendor_id = current_user.token_vendor_id
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
if not order.letzshop_shipment_id:
raise ValidationException("Order does not have a shipment ID")
try:
with creds_service.create_client(vendor_id) as client:
result = client.set_shipment_tracking(
shipment_id=order.letzshop_shipment_id,
tracking_code=tracking_request.tracking_number,
tracking_provider=tracking_request.tracking_carrier,
)
if result.get("errors"):
error_messages = [
f"{e.get('code', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["errors"]
]
return FulfillmentOperationResponse(
success=False,
message="Failed to set tracking",
errors=error_messages,
)
order_service.set_order_tracking(
order,
tracking_request.tracking_number,
tracking_request.tracking_carrier,
)
db.commit()
return FulfillmentOperationResponse(
success=True,
message="Tracking information set",
tracking_number=tracking_request.tracking_number,
tracking_carrier=tracking_request.tracking_carrier,
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
# ============================================================================
# Sync Logs
# ============================================================================
@router.get("/logs", response_model=LetzshopSyncLogListResponse)
def list_sync_logs(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List Letzshop sync logs for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
logs, total = order_service.list_sync_logs(
vendor_id=vendor_id,
skip=skip,
limit=limit,
)
return LetzshopSyncLogListResponse(
logs=[
LetzshopSyncLogResponse(
id=log.id,
vendor_id=log.vendor_id,
operation_type=log.operation_type,
direction=log.direction,
status=log.status,
records_processed=log.records_processed,
records_succeeded=log.records_succeeded,
records_failed=log.records_failed,
error_details=log.error_details,
started_at=log.started_at,
completed_at=log.completed_at,
duration_seconds=log.duration_seconds,
triggered_by=log.triggered_by,
created_at=log.created_at,
)
for log in logs
],
total=total,
skip=skip,
limit=limit,
)
# ============================================================================
# Fulfillment Queue
# ============================================================================
@router.get("/queue", response_model=FulfillmentQueueListResponse)
def list_fulfillment_queue(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by status"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List fulfillment queue items for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
items, total = order_service.list_fulfillment_queue(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
)
return FulfillmentQueueListResponse(
items=[
FulfillmentQueueItemResponse(
id=item.id,
vendor_id=item.vendor_id,
letzshop_order_id=item.letzshop_order_id,
operation=item.operation,
payload=item.payload,
status=item.status,
attempts=item.attempts,
max_attempts=item.max_attempts,
last_attempt_at=item.last_attempt_at,
next_retry_at=item.next_retry_at,
error_message=item.error_message,
completed_at=item.completed_at,
response_data=item.response_data,
created_at=item.created_at,
updated_at=item.updated_at,
)
for item in items
],
total=total,
skip=skip,
limit=limit,
)