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:
319
app/services/letzshop/order_service.py
Normal file
319
app/services/letzshop/order_service.py
Normal file
@@ -0,0 +1,319 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user