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>
This commit is contained in:
@@ -8,7 +8,7 @@ for all Letzshop API operations.
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
import requests
|
||||
|
||||
@@ -43,63 +43,72 @@ class LetzshopConnectionError(LetzshopClientError):
|
||||
# GraphQL Queries
|
||||
# ============================================================================
|
||||
|
||||
QUERY_SHIPMENTS = """
|
||||
query GetShipments($state: ShipmentState) {
|
||||
shipments(state: $state) {
|
||||
QUERY_SHIPMENTS_UNCONFIRMED = """
|
||||
query {
|
||||
shipments(state: unconfirmed) {
|
||||
nodes {
|
||||
id
|
||||
number
|
||||
state
|
||||
createdAt
|
||||
updatedAt
|
||||
order {
|
||||
id
|
||||
number
|
||||
email
|
||||
totalPrice {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
lineItems {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
quantity
|
||||
price {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
total
|
||||
completedAt
|
||||
locale
|
||||
shipAddress {
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
streetName
|
||||
streetNumber
|
||||
city
|
||||
zipCode
|
||||
phone
|
||||
country {
|
||||
name { en fr de }
|
||||
iso
|
||||
}
|
||||
}
|
||||
shippingAddress {
|
||||
billAddress {
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
streetName
|
||||
streetNumber
|
||||
city
|
||||
zip
|
||||
country
|
||||
}
|
||||
billingAddress {
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
city
|
||||
zip
|
||||
country
|
||||
zipCode
|
||||
phone
|
||||
country {
|
||||
name { en fr de }
|
||||
iso
|
||||
}
|
||||
}
|
||||
}
|
||||
inventoryUnits {
|
||||
nodes {
|
||||
id
|
||||
state
|
||||
variant {
|
||||
id
|
||||
state
|
||||
variant {
|
||||
id
|
||||
sku
|
||||
name
|
||||
sku
|
||||
mpn
|
||||
price
|
||||
tradeId {
|
||||
number
|
||||
parser
|
||||
}
|
||||
product {
|
||||
name {
|
||||
en
|
||||
fr
|
||||
de
|
||||
}
|
||||
_brand {
|
||||
... on Brand {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,9 +117,83 @@ query GetShipments($state: ShipmentState) {
|
||||
provider
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,25 +206,65 @@ query GetShipment($id: ID!) {
|
||||
id
|
||||
number
|
||||
state
|
||||
createdAt
|
||||
updatedAt
|
||||
order {
|
||||
id
|
||||
number
|
||||
email
|
||||
totalPrice {
|
||||
amount
|
||||
currency
|
||||
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 {
|
||||
nodes {
|
||||
id
|
||||
state
|
||||
variant {
|
||||
id
|
||||
state
|
||||
variant {
|
||||
id
|
||||
sku
|
||||
name
|
||||
sku
|
||||
mpn
|
||||
price
|
||||
tradeId {
|
||||
number
|
||||
parser
|
||||
}
|
||||
product {
|
||||
name {
|
||||
en
|
||||
fr
|
||||
de
|
||||
}
|
||||
_brand {
|
||||
... on Brand {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +277,83 @@ query GetShipment($id: ID!) {
|
||||
}
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
# ============================================================================
|
||||
@@ -327,11 +527,14 @@ class LetzshopClient:
|
||||
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,
|
||||
@@ -372,24 +575,31 @@ class LetzshopClient:
|
||||
|
||||
def get_shipments(
|
||||
self,
|
||||
state: str | None = None,
|
||||
state: str = "unconfirmed",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get shipments from Letzshop.
|
||||
|
||||
Args:
|
||||
state: Optional state filter (e.g., "unconfirmed", "confirmed").
|
||||
state: State filter ("unconfirmed" or "confirmed").
|
||||
|
||||
Returns:
|
||||
List of shipment data dictionaries.
|
||||
"""
|
||||
variables = {}
|
||||
if state:
|
||||
variables["state"] = state
|
||||
# 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
|
||||
|
||||
data = self._execute(QUERY_SHIPMENTS, variables)
|
||||
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", {})
|
||||
return shipments_data.get("nodes", [])
|
||||
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."""
|
||||
@@ -408,6 +618,70 @@ class LetzshopClient:
|
||||
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
|
||||
# ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user