Files
orion/app/services/letzshop/client_service.py
Samir Boulahtit 0ab10128ae feat: enhance Letzshop order import with EAN matching and stats
- Add historical order import with pagination support
- Add customer_locale, shipping_country_iso, billing_country_iso columns
- Add gtin/gtin_type columns to Product table for EAN matching
- Fix order stats to count all orders server-side (not just visible page)
- Add GraphQL introspection script with tracking workaround tests
- Enrich inventory units with EAN, MPN, SKU, product name
- Add LetzshopOrderStats schema for proper status counts

Migrations:
- a9a86cef6cca: Add locale and country fields to letzshop_orders
- cb88bc9b5f86: Add gtin columns to products table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:04:33 +01:00

762 lines
21 KiB
Python

# app/services/letzshop/client_service.py
"""
GraphQL client for Letzshop marketplace API.
Handles authentication, request formatting, and error handling
for all Letzshop API operations.
"""
import logging
import time
from typing import Any, Callable
import requests
logger = logging.getLogger(__name__)
# Default API endpoint
DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
class LetzshopClientError(Exception):
"""Base exception for Letzshop client errors."""
def __init__(self, message: str, response_data: dict | None = None):
super().__init__(message)
self.message = message
self.response_data = response_data
class LetzshopAuthError(LetzshopClientError):
"""Raised when authentication fails."""
class LetzshopAPIError(LetzshopClientError):
"""Raised when the API returns an error response."""
class LetzshopConnectionError(LetzshopClientError):
"""Raised when connection to the API fails."""
# ============================================================================
# GraphQL Queries
# ============================================================================
QUERY_SHIPMENTS_UNCONFIRMED = """
query {
shipments(state: unconfirmed) {
nodes {
id
number
state
order {
id
number
email
total
completedAt
locale
shipAddress {
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {
name { en fr de }
iso
}
}
billAddress {
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {
name { en fr de }
iso
}
}
}
inventoryUnits {
id
state
variant {
id
sku
mpn
price
tradeId {
number
parser
}
product {
name {
en
fr
de
}
_brand {
... on Brand {
name
}
}
}
}
}
tracking {
code
provider
}
}
}
}
"""
QUERY_SHIPMENTS_CONFIRMED = """
query {
shipments(state: confirmed) {
nodes {
id
number
state
order {
id
number
email
total
completedAt
locale
shipAddress {
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {
name { en fr de }
iso
}
}
billAddress {
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {
name { en fr de }
iso
}
}
}
inventoryUnits {
id
state
variant {
id
sku
mpn
price
tradeId {
number
parser
}
product {
name {
en
fr
de
}
_brand {
... on Brand {
name
}
}
}
}
}
tracking {
code
provider
}
}
}
}
"""
QUERY_SHIPMENT_BY_ID = """
query GetShipment($id: ID!) {
node(id: $id) {
... on Shipment {
id
number
state
order {
id
number
email
total
completedAt
locale
shipAddress {
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {
name { en fr de }
iso
}
}
billAddress {
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {
name { en fr de }
iso
}
}
}
inventoryUnits {
id
state
variant {
id
sku
mpn
price
tradeId {
number
parser
}
product {
name {
en
fr
de
}
_brand {
... on Brand {
name
}
}
}
}
}
tracking {
code
provider
}
}
}
}
"""
# ============================================================================
# Paginated Queries (for historical import)
# ============================================================================
# Note: Using string formatting for state since Letzshop has issues with enum variables
# Note: tracking field removed - causes 'demodulize' server error on some shipments
QUERY_SHIPMENTS_PAGINATED_TEMPLATE = """
query GetShipmentsPaginated($first: Int!, $after: String) {{
shipments(state: {state}, first: $first, after: $after) {{
pageInfo {{
hasNextPage
endCursor
}}
nodes {{
id
number
state
order {{
id
number
email
total
completedAt
locale
shipAddress {{
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {{
iso
}}
}}
billAddress {{
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {{
iso
}}
}}
}}
inventoryUnits {{
id
state
variant {{
id
sku
mpn
price
tradeId {{
number
parser
}}
product {{
name {{
en
fr
de
}}
}}
}}
}}
}}
}}
}}
"""
# ============================================================================
# GraphQL Mutations
# ============================================================================
MUTATION_CONFIRM_INVENTORY_UNITS = """
mutation ConfirmInventoryUnits($input: ConfirmInventoryUnitsInput!) {
confirmInventoryUnits(input: $input) {
inventoryUnits {
id
state
}
errors {
id
code
message
}
}
}
"""
MUTATION_REJECT_INVENTORY_UNITS = """
mutation RejectInventoryUnits($input: RejectInventoryUnitsInput!) {
returnInventoryUnits(input: $input) {
inventoryUnits {
id
state
}
errors {
id
code
message
}
}
}
"""
MUTATION_SET_SHIPMENT_TRACKING = """
mutation SetShipmentTracking($input: SetShipmentTrackingInput!) {
setShipmentTracking(input: $input) {
shipment {
id
tracking {
code
provider
}
}
errors {
code
message
}
}
}
"""
class LetzshopClient:
"""
GraphQL client for Letzshop marketplace API.
Usage:
client = LetzshopClient(api_key="your-api-key")
shipments = client.get_shipments(state="unconfirmed")
"""
def __init__(
self,
api_key: str,
endpoint: str = DEFAULT_ENDPOINT,
timeout: int = 30,
):
"""
Initialize the Letzshop client.
Args:
api_key: The Letzshop API key (Bearer token).
endpoint: The GraphQL endpoint URL.
timeout: Request timeout in seconds.
"""
self.api_key = api_key
self.endpoint = endpoint
self.timeout = timeout
self._session: requests.Session | None = None
@property
def session(self) -> requests.Session:
"""Get or create a requests session."""
if self._session is None:
self._session = requests.Session()
self._session.headers.update(
{
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
)
return self._session
def close(self) -> None:
"""Close the HTTP session."""
if self._session is not None:
self._session.close()
self._session = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
def _execute(
self,
query: str,
variables: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Execute a GraphQL query or mutation.
Args:
query: The GraphQL query or mutation string.
variables: Optional variables for the query.
Returns:
The response data from the API.
Raises:
LetzshopAuthError: If authentication fails.
LetzshopAPIError: If the API returns an error.
LetzshopConnectionError: If the request fails.
"""
payload = {"query": query}
if variables:
payload["variables"] = variables
logger.debug(f"Executing GraphQL request to {self.endpoint}")
try:
response = self.session.post(
self.endpoint,
json=payload,
timeout=self.timeout,
)
except requests.exceptions.Timeout as e:
raise LetzshopConnectionError(f"Request timed out: {e}") from e
except requests.exceptions.ConnectionError as e:
raise LetzshopConnectionError(f"Connection failed: {e}") from e
except requests.exceptions.RequestException as e:
raise LetzshopConnectionError(f"Request failed: {e}") from e
# Handle HTTP-level errors
if response.status_code == 401:
raise LetzshopAuthError(
"Authentication failed. Please check your API key.",
response_data={"status_code": 401},
)
if response.status_code == 403:
raise LetzshopAuthError(
"Access forbidden. Your API key may not have the required permissions.",
response_data={"status_code": 403},
)
if response.status_code >= 500:
raise LetzshopAPIError(
f"Letzshop server error (HTTP {response.status_code})",
response_data={"status_code": response.status_code},
)
# Parse JSON response
try:
data = response.json()
except ValueError as e:
raise LetzshopAPIError(
f"Invalid JSON response: {response.text[:200]}"
) from e
logger.debug(f"GraphQL response: {data}")
# Check for GraphQL errors
if "errors" in data and data["errors"]:
error_messages = [
err.get("message", "Unknown error") for err in data["errors"]
]
logger.warning(f"GraphQL errors received: {data['errors']}")
raise LetzshopAPIError(
f"GraphQL errors: {'; '.join(error_messages)}",
response_data=data,
)
return data.get("data", {})
# ========================================================================
# Connection Testing
# ========================================================================
def test_connection(self) -> tuple[bool, float, str | None]:
"""
Test the connection to Letzshop API.
Returns:
Tuple of (success, response_time_ms, error_message).
"""
test_query = """
query TestConnection {
__typename
}
"""
start_time = time.time()
try:
self._execute(test_query)
elapsed_ms = (time.time() - start_time) * 1000
return True, elapsed_ms, None
except LetzshopClientError as e:
elapsed_ms = (time.time() - start_time) * 1000
return False, elapsed_ms, str(e)
# ========================================================================
# Shipment Queries
# ========================================================================
def get_shipments(
self,
state: str = "unconfirmed",
) -> list[dict[str, Any]]:
"""
Get shipments from Letzshop.
Args:
state: State filter ("unconfirmed" or "confirmed").
Returns:
List of shipment data dictionaries.
"""
# Use pre-built queries with inline state values
# (Letzshop's GraphQL has issues with enum variables)
if state == "confirmed":
query = QUERY_SHIPMENTS_CONFIRMED
else:
query = QUERY_SHIPMENTS_UNCONFIRMED
logger.debug(f"Fetching shipments with state: {state}")
data = self._execute(query)
logger.debug(f"Shipments response data keys: {data.keys() if data else 'None'}")
shipments_data = data.get("shipments", {})
nodes = shipments_data.get("nodes", [])
logger.info(f"Got {len(nodes)} {state} shipments from Letzshop API")
return nodes
def get_unconfirmed_shipments(self) -> list[dict[str, Any]]:
"""Get all unconfirmed shipments."""
return self.get_shipments(state="unconfirmed")
def get_shipment_by_id(self, shipment_id: str) -> dict[str, Any] | None:
"""
Get a single shipment by its ID.
Args:
shipment_id: The Letzshop shipment ID.
Returns:
Shipment data or None if not found.
"""
data = self._execute(QUERY_SHIPMENT_BY_ID, {"id": shipment_id})
return data.get("node")
def get_all_shipments_paginated(
self,
state: str = "confirmed",
page_size: int = 50,
max_pages: int | None = None,
progress_callback: Callable[[int, int], None] | None = None,
) -> list[dict[str, Any]]:
"""
Fetch all shipments with pagination support.
Args:
state: State filter ("unconfirmed" or "confirmed").
page_size: Number of shipments per page (default 50).
max_pages: Maximum number of pages to fetch (None = all).
progress_callback: Optional callback(page, total_fetched) for progress updates.
Returns:
List of all shipment data dictionaries.
"""
query = QUERY_SHIPMENTS_PAGINATED_TEMPLATE.format(state=state)
all_shipments = []
cursor = None
page = 0
while True:
page += 1
variables = {"first": page_size}
if cursor:
variables["after"] = cursor
logger.info(f"Fetching {state} shipments page {page} (cursor: {cursor})")
try:
data = self._execute(query, variables)
except LetzshopAPIError as e:
# Log error but return what we have so far
logger.error(f"Error fetching page {page}: {e}")
break
shipments_data = data.get("shipments", {})
nodes = shipments_data.get("nodes", [])
page_info = shipments_data.get("pageInfo", {})
all_shipments.extend(nodes)
if progress_callback:
progress_callback(page, len(all_shipments))
logger.info(f"Page {page}: fetched {len(nodes)} shipments, total: {len(all_shipments)}")
# Check if there are more pages
if not page_info.get("hasNextPage"):
logger.info(f"Reached last page. Total shipments: {len(all_shipments)}")
break
cursor = page_info.get("endCursor")
# Check max pages limit
if max_pages and page >= max_pages:
logger.info(f"Reached max pages limit ({max_pages}). Total shipments: {len(all_shipments)}")
break
return all_shipments
# ========================================================================
# Fulfillment Mutations
# ========================================================================
def confirm_inventory_units(
self,
inventory_unit_ids: list[str],
) -> dict[str, Any]:
"""
Confirm inventory units for fulfillment.
Args:
inventory_unit_ids: List of inventory unit IDs to confirm.
Returns:
Response data including confirmed units and any errors.
"""
variables = {
"input": {
"inventoryUnitIds": inventory_unit_ids,
}
}
data = self._execute(MUTATION_CONFIRM_INVENTORY_UNITS, variables)
return data.get("confirmInventoryUnits", {})
def reject_inventory_units(
self,
inventory_unit_ids: list[str],
) -> dict[str, Any]:
"""
Reject/return inventory units.
Args:
inventory_unit_ids: List of inventory unit IDs to reject.
Returns:
Response data including rejected units and any errors.
"""
variables = {
"input": {
"inventoryUnitIds": inventory_unit_ids,
}
}
data = self._execute(MUTATION_REJECT_INVENTORY_UNITS, variables)
return data.get("returnInventoryUnits", {})
def set_shipment_tracking(
self,
shipment_id: str,
tracking_code: str,
tracking_provider: str,
) -> dict[str, Any]:
"""
Set tracking information for a shipment.
Args:
shipment_id: The Letzshop shipment ID.
tracking_code: The tracking number.
tracking_provider: The carrier code (e.g., "dhl", "ups").
Returns:
Response data including updated shipment and any errors.
"""
variables = {
"input": {
"shipmentId": shipment_id,
"tracking": {
"code": tracking_code,
"provider": tracking_provider,
},
}
}
data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables)
return data.get("setShipmentTracking", {})