Files
orion/app/services/letzshop/order_service.py
Samir Boulahtit 448f01f82b 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>
2025-12-13 12:19:54 +01:00

320 lines
10 KiB
Python

# app/services/letzshop/order_service.py
"""
Letzshop order service for handling order-related database operations.
This service moves database queries out of the API layer to comply with
architecture rules (API-002: endpoints should not contain business logic).
"""
import logging
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from models.database.letzshop import (
LetzshopFulfillmentQueue,
LetzshopOrder,
LetzshopSyncLog,
VendorLetzshopCredentials,
)
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class VendorNotFoundError(Exception):
"""Raised when a vendor is not found."""
pass
class OrderNotFoundError(Exception):
"""Raised when a Letzshop order is not found."""
pass
class LetzshopOrderService:
"""Service for Letzshop order database operations."""
def __init__(self, db: Session):
self.db = db
# =========================================================================
# Vendor Operations
# =========================================================================
def get_vendor(self, vendor_id: int) -> Vendor | None:
"""Get vendor by ID."""
return self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
def get_vendor_or_raise(self, vendor_id: int) -> Vendor:
"""Get vendor by ID or raise VendorNotFoundError."""
vendor = self.get_vendor(vendor_id)
if vendor is None:
raise VendorNotFoundError(f"Vendor with ID {vendor_id} not found")
return vendor
def list_vendors_with_letzshop_status(
self,
skip: int = 0,
limit: int = 100,
configured_only: bool = False,
) -> tuple[list[dict[str, Any]], int]:
"""
List vendors with their Letzshop integration status.
Returns a tuple of (vendor_overviews, total_count).
"""
# Build query
query = self.db.query(Vendor).filter(Vendor.is_active == True) # noqa: E712
if configured_only:
query = query.join(
VendorLetzshopCredentials,
Vendor.id == VendorLetzshopCredentials.vendor_id,
)
# Get total count
total = query.count()
# Get vendors
vendors = query.order_by(Vendor.name).offset(skip).limit(limit).all()
# Build response with Letzshop status
vendor_overviews = []
for vendor in vendors:
# Get credentials
credentials = (
self.db.query(VendorLetzshopCredentials)
.filter(VendorLetzshopCredentials.vendor_id == vendor.id)
.first()
)
# Get order counts
pending_orders = 0
total_orders = 0
if credentials:
pending_orders = (
self.db.query(func.count(LetzshopOrder.id))
.filter(
LetzshopOrder.vendor_id == vendor.id,
LetzshopOrder.sync_status == "pending",
)
.scalar()
or 0
)
total_orders = (
self.db.query(func.count(LetzshopOrder.id))
.filter(LetzshopOrder.vendor_id == vendor.id)
.scalar()
or 0
)
vendor_overviews.append({
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"vendor_code": vendor.vendor_code,
"is_configured": credentials is not None,
"auto_sync_enabled": credentials.auto_sync_enabled if credentials else False,
"last_sync_at": credentials.last_sync_at if credentials else None,
"last_sync_status": credentials.last_sync_status if credentials else None,
"pending_orders": pending_orders,
"total_orders": total_orders,
})
return vendor_overviews, total
# =========================================================================
# Order Operations
# =========================================================================
def get_order(self, vendor_id: int, order_id: int) -> LetzshopOrder | None:
"""Get a Letzshop order by ID for a specific vendor."""
return (
self.db.query(LetzshopOrder)
.filter(
LetzshopOrder.id == order_id,
LetzshopOrder.vendor_id == vendor_id,
)
.first()
)
def get_order_or_raise(self, vendor_id: int, order_id: int) -> LetzshopOrder:
"""Get a Letzshop order or raise OrderNotFoundError."""
order = self.get_order(vendor_id, order_id)
if order is None:
raise OrderNotFoundError(f"Letzshop order {order_id} not found")
return order
def get_order_by_shipment_id(
self, vendor_id: int, shipment_id: str
) -> LetzshopOrder | None:
"""Get a Letzshop order by shipment ID."""
return (
self.db.query(LetzshopOrder)
.filter(
LetzshopOrder.vendor_id == vendor_id,
LetzshopOrder.letzshop_shipment_id == shipment_id,
)
.first()
)
def list_orders(
self,
vendor_id: int,
skip: int = 0,
limit: int = 50,
sync_status: str | None = None,
letzshop_state: str | None = None,
) -> tuple[list[LetzshopOrder], int]:
"""
List Letzshop orders for a vendor.
Returns a tuple of (orders, total_count).
"""
query = self.db.query(LetzshopOrder).filter(
LetzshopOrder.vendor_id == vendor_id
)
if sync_status:
query = query.filter(LetzshopOrder.sync_status == sync_status)
if letzshop_state:
query = query.filter(LetzshopOrder.letzshop_state == letzshop_state)
total = query.count()
orders = (
query.order_by(LetzshopOrder.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return orders, total
def create_order(
self,
vendor_id: int,
shipment_data: dict[str, Any],
) -> LetzshopOrder:
"""Create a new Letzshop order from shipment data."""
order_data = shipment_data.get("order", {})
order = LetzshopOrder(
vendor_id=vendor_id,
letzshop_order_id=order_data.get("id", ""),
letzshop_shipment_id=shipment_data["id"],
letzshop_order_number=order_data.get("number"),
letzshop_state=shipment_data.get("state"),
customer_email=order_data.get("email"),
total_amount=str(
order_data.get("totalPrice", {}).get("amount", "")
),
currency=order_data.get("totalPrice", {}).get("currency", "EUR"),
raw_order_data=shipment_data,
inventory_units=[
{"id": u["id"], "state": u["state"]}
for u in shipment_data.get("inventoryUnits", {}).get("nodes", [])
],
sync_status="pending",
)
self.db.add(order)
return order
def update_order_from_shipment(
self,
order: LetzshopOrder,
shipment_data: dict[str, Any],
) -> LetzshopOrder:
"""Update an existing order from shipment data."""
order.letzshop_state = shipment_data.get("state")
order.raw_order_data = shipment_data
return order
def mark_order_confirmed(self, order: LetzshopOrder) -> LetzshopOrder:
"""Mark an order as confirmed."""
order.confirmed_at = datetime.now(timezone.utc)
order.sync_status = "confirmed"
return order
def mark_order_rejected(self, order: LetzshopOrder) -> LetzshopOrder:
"""Mark an order as rejected."""
order.rejected_at = datetime.now(timezone.utc)
order.sync_status = "rejected"
return order
def set_order_tracking(
self,
order: LetzshopOrder,
tracking_number: str,
tracking_carrier: str,
) -> LetzshopOrder:
"""Set tracking information for an order."""
order.tracking_number = tracking_number
order.tracking_carrier = tracking_carrier
order.tracking_set_at = datetime.now(timezone.utc)
order.sync_status = "shipped"
return order
# =========================================================================
# Sync Log Operations
# =========================================================================
def list_sync_logs(
self,
vendor_id: int,
skip: int = 0,
limit: int = 50,
) -> tuple[list[LetzshopSyncLog], int]:
"""
List sync logs for a vendor.
Returns a tuple of (logs, total_count).
"""
query = self.db.query(LetzshopSyncLog).filter(
LetzshopSyncLog.vendor_id == vendor_id
)
total = query.count()
logs = (
query.order_by(LetzshopSyncLog.started_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return logs, total
# =========================================================================
# Fulfillment Queue Operations
# =========================================================================
def list_fulfillment_queue(
self,
vendor_id: int,
skip: int = 0,
limit: int = 50,
status: str | None = None,
) -> tuple[list[LetzshopFulfillmentQueue], int]:
"""
List fulfillment queue items for a vendor.
Returns a tuple of (items, total_count).
"""
query = self.db.query(LetzshopFulfillmentQueue).filter(
LetzshopFulfillmentQueue.vendor_id == vendor_id
)
if status:
query = query.filter(LetzshopFulfillmentQueue.status == status)
total = query.count()
items = (
query.order_by(LetzshopFulfillmentQueue.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return items, total