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:
2025-12-18 21:04:33 +01:00
parent 6d6c8b44d3
commit 0ab10128ae
17 changed files with 3451 additions and 94 deletions

View File

@@ -0,0 +1,31 @@
"""add_letzshop_order_locale_and_country_fields
Revision ID: a9a86cef6cca
Revises: fcfdc02d5138
Create Date: 2025-12-17 20:55:41.477848
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a9a86cef6cca'
down_revision: Union[str, None] = 'fcfdc02d5138'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new columns to letzshop_orders for customer locale and country
op.add_column('letzshop_orders', sa.Column('customer_locale', sa.String(length=10), nullable=True))
op.add_column('letzshop_orders', sa.Column('shipping_country_iso', sa.String(length=5), nullable=True))
op.add_column('letzshop_orders', sa.Column('billing_country_iso', sa.String(length=5), nullable=True))
def downgrade() -> None:
op.drop_column('letzshop_orders', 'billing_country_iso')
op.drop_column('letzshop_orders', 'shipping_country_iso')
op.drop_column('letzshop_orders', 'customer_locale')

View File

@@ -0,0 +1,37 @@
"""add_gtin_columns_to_product_table
Revision ID: cb88bc9b5f86
Revises: a9a86cef6cca
Create Date: 2025-12-18 20:54:55.185857
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cb88bc9b5f86'
down_revision: Union[str, None] = 'a9a86cef6cca'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add GTIN (EAN/UPC barcode) columns to products table for order EAN matching
# gtin: The barcode number (e.g., "0889698273022")
# gtin_type: The format type from Letzshop (e.g., "gtin13", "gtin14", "isbn13")
op.add_column('products', sa.Column('gtin', sa.String(length=50), nullable=True))
op.add_column('products', sa.Column('gtin_type', sa.String(length=20), nullable=True))
# Add index for EAN lookups during order matching
op.create_index('idx_product_gtin', 'products', ['gtin'], unique=False)
op.create_index('idx_product_vendor_gtin', 'products', ['vendor_id', 'gtin'], unique=False)
def downgrade() -> None:
op.drop_index('idx_product_vendor_gtin', table_name='products')
op.drop_index('idx_product_gtin', table_name='products')
op.drop_column('products', 'gtin_type')
op.drop_column('products', 'gtin')

View File

@@ -361,6 +361,9 @@ def list_vendor_letzshop_orders(
sync_status=sync_status,
)
# Get order stats for all statuses
stats = order_service.get_order_stats(vendor_id)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
@@ -392,6 +395,7 @@ def list_vendor_letzshop_orders(
total=total,
skip=skip,
limit=limit,
stats=stats,
)
@@ -430,6 +434,10 @@ def trigger_vendor_sync(
try:
with creds_service.create_client(vendor_id) as client:
shipments = client.get_unconfirmed_shipments()
logger.info(
f"Letzshop sync for vendor {vendor_id}: "
f"fetched {len(shipments)} unconfirmed shipments from API"
)
orders_imported = 0
orders_updated = 0
@@ -524,3 +532,118 @@ def list_vendor_letzshop_jobs(
jobs = [LetzshopJobItem(**job) for job in jobs_data]
return LetzshopJobsListResponse(jobs=jobs, total=total)
# ============================================================================
# Historical Import
# ============================================================================
@router.post(
"/vendors/{vendor_id}/import-history",
)
def import_historical_orders(
vendor_id: int = Path(..., description="Vendor ID"),
state: str = Query("confirmed", description="Shipment state to import"),
max_pages: int | None = Query(None, ge=1, le=100, description="Max pages to fetch"),
match_products: bool = Query(True, description="Match EANs to local products"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Import historical orders from Letzshop.
Fetches all shipments with the specified state (default: confirmed)
and imports them into the database. Supports pagination and EAN matching.
Returns statistics on imported/updated/skipped orders and product matching.
"""
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}"
)
# Fetch all shipments with pagination
try:
with creds_service.create_client(vendor_id) as client:
logger.info(
f"Starting historical import for vendor {vendor_id}, state={state}, max_pages={max_pages}"
)
shipments = client.get_all_shipments_paginated(
state=state,
page_size=50,
max_pages=max_pages,
)
logger.info(f"Fetched {len(shipments)} {state} shipments from Letzshop")
# Import shipments
stats = order_service.import_historical_shipments(
vendor_id=vendor_id,
shipments=shipments,
match_products=match_products,
)
db.commit()
# Update sync status
creds_service.update_sync_status(
vendor_id,
"success",
None,
)
logger.info(
f"Historical import completed: {stats['imported']} imported, "
f"{stats['updated']} updated, {stats['skipped']} skipped"
)
return {
"success": True,
"message": f"Historical import completed: {stats['imported']} imported, {stats['updated']} updated",
"statistics": stats,
}
except LetzshopClientError as e:
creds_service.update_sync_status(vendor_id, "failed", str(e))
raise ValidationException(f"Letzshop API error: {e}")
@router.get(
"/vendors/{vendor_id}/import-summary",
)
def get_import_summary(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get summary statistics for imported Letzshop orders.
Returns total orders, unique customers, and breakdowns by state/locale/country.
"""
order_service = get_order_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
summary = order_service.get_historical_import_summary(vendor_id)
return {
"success": True,
"summary": summary,
}

View File

@@ -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
# ========================================================================

View File

@@ -20,6 +20,7 @@ from models.database.letzshop import (
VendorLetzshopCredentials,
)
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.product import Product
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
@@ -197,6 +198,31 @@ class LetzshopOrderService:
return orders, total
def get_order_stats(self, vendor_id: int) -> dict[str, int]:
"""
Get order counts by sync_status for a vendor.
Returns:
Dict with counts for each status: pending, confirmed, rejected, shipped
"""
status_counts = (
self.db.query(
LetzshopOrder.sync_status,
func.count(LetzshopOrder.id).label("count"),
)
.filter(LetzshopOrder.vendor_id == vendor_id)
.group_by(LetzshopOrder.sync_status)
.all()
)
# Convert to dict with default 0 for missing statuses
stats = {"pending": 0, "confirmed": 0, "rejected": 0, "shipped": 0}
for status, count in status_counts:
if status in stats:
stats[status] = count
return stats
def create_order(
self,
vendor_id: int,
@@ -205,21 +231,94 @@ class LetzshopOrderService:
"""Create a new Letzshop order from shipment data."""
order_data = shipment_data.get("order", {})
# Handle total - can be a string like "99.99 EUR" or just a number
total = order_data.get("total", "")
total_amount = str(total) if total else ""
# Default currency to EUR (Letzshop is Luxembourg-based)
currency = "EUR"
# Extract customer name from shipping address
ship_address = order_data.get("shipAddress", {}) or {}
first_name = ship_address.get("firstName", "") or ""
last_name = ship_address.get("lastName", "") or ""
customer_name = f"{first_name} {last_name}".strip() or None
# Extract customer locale (language preference for invoicing)
customer_locale = order_data.get("locale")
# Extract country codes
ship_country = ship_address.get("country", {}) or {}
shipping_country_iso = ship_country.get("iso")
bill_address = order_data.get("billAddress", {}) or {}
bill_country = bill_address.get("country", {}) or {}
billing_country_iso = bill_country.get("iso")
# inventoryUnits is a direct array, not wrapped in nodes
inventory_units_data = shipment_data.get("inventoryUnits", [])
if isinstance(inventory_units_data, dict):
# Handle legacy format with nodes wrapper
inventory_units_data = inventory_units_data.get("nodes", [])
# Extract enriched inventory unit data with product details
enriched_units = []
for unit in inventory_units_data:
variant = unit.get("variant", {}) or {}
product = variant.get("product", {}) or {}
trade_id = variant.get("tradeId") or {}
product_name = product.get("name", {}) or {}
enriched_unit = {
"id": unit.get("id"),
"state": unit.get("state"),
# Product identifiers
"ean": trade_id.get("number"),
"ean_type": trade_id.get("parser"),
"sku": variant.get("sku"),
"mpn": variant.get("mpn"),
# Product info
"product_name": (
product_name.get("en")
or product_name.get("fr")
or product_name.get("de")
),
"product_name_translations": product_name,
# Pricing
"price": variant.get("price"),
"variant_id": variant.get("id"),
}
enriched_units.append(enriched_unit)
# Map Letzshop state to sync_status
# Letzshop shipment states (from docs):
# - unconfirmed: needs to be confirmed/rejected
# - confirmed: at least one product confirmed
# - declined: all products rejected
# Note: "shipped" is not a state - tracking is set separately via tracking field
letzshop_state = shipment_data.get("state", "unconfirmed")
state_mapping = {
"unconfirmed": "pending",
"confirmed": "confirmed",
"declined": "rejected",
}
sync_status = state_mapping.get(letzshop_state, "confirmed")
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"),
letzshop_state=letzshop_state,
customer_email=order_data.get("email"),
total_amount=str(order_data.get("totalPrice", {}).get("amount", "")),
currency=order_data.get("totalPrice", {}).get("currency", "EUR"),
customer_name=customer_name,
customer_locale=customer_locale,
shipping_country_iso=shipping_country_iso,
billing_country_iso=billing_country_iso,
total_amount=total_amount,
currency=currency,
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",
inventory_units=enriched_units,
sync_status=sync_status,
)
self.db.add(order)
return order
@@ -230,8 +329,66 @@ class LetzshopOrderService:
shipment_data: dict[str, Any],
) -> LetzshopOrder:
"""Update an existing order from shipment data."""
order.letzshop_state = shipment_data.get("state")
order_data = shipment_data.get("order", {})
# Update letzshop_state and sync_status
# Letzshop states: unconfirmed, confirmed, declined
letzshop_state = shipment_data.get("state", "unconfirmed")
state_mapping = {
"unconfirmed": "pending",
"confirmed": "confirmed",
"declined": "rejected",
}
order.letzshop_state = letzshop_state
order.sync_status = state_mapping.get(letzshop_state, "confirmed")
order.raw_order_data = shipment_data
# Update locale if not already set
if not order.customer_locale and order_data.get("locale"):
order.customer_locale = order_data.get("locale")
# Update country codes if not already set
if not order.shipping_country_iso:
ship_address = order_data.get("shipAddress", {}) or {}
ship_country = ship_address.get("country", {}) or {}
order.shipping_country_iso = ship_country.get("iso")
if not order.billing_country_iso:
bill_address = order_data.get("billAddress", {}) or {}
bill_country = bill_address.get("country", {}) or {}
order.billing_country_iso = bill_country.get("iso")
# Update enriched inventory units
inventory_units_data = shipment_data.get("inventoryUnits", [])
if isinstance(inventory_units_data, dict):
inventory_units_data = inventory_units_data.get("nodes", [])
enriched_units = []
for unit in inventory_units_data:
variant = unit.get("variant", {}) or {}
product = variant.get("product", {}) or {}
trade_id = variant.get("tradeId") or {}
product_name = product.get("name", {}) or {}
enriched_unit = {
"id": unit.get("id"),
"state": unit.get("state"),
"ean": trade_id.get("number"),
"ean_type": trade_id.get("parser"),
"sku": variant.get("sku"),
"mpn": variant.get("mpn"),
"product_name": (
product_name.get("en")
or product_name.get("fr")
or product_name.get("de")
),
"product_name_translations": product_name,
"price": variant.get("price"),
"variant_id": variant.get("id"),
}
enriched_units.append(enriched_unit)
order.inventory_units = enriched_units
return order
def mark_order_confirmed(self, order: LetzshopOrder) -> LetzshopOrder:
@@ -415,3 +572,222 @@ class LetzshopOrderService:
jobs = jobs[skip : skip + limit]
return jobs, total
# =========================================================================
# Historical Import Operations
# =========================================================================
def import_historical_shipments(
self,
vendor_id: int,
shipments: list[dict[str, Any]],
match_products: bool = True,
) -> dict[str, Any]:
"""
Import historical shipments into the database.
Args:
vendor_id: Vendor ID to import for.
shipments: List of shipment data from Letzshop API.
match_products: Whether to match EAN to local products.
Returns:
Dict with import statistics:
- total: Total shipments processed
- imported: New orders created
- updated: Existing orders updated
- skipped: Already up-to-date orders
- products_matched: Products matched by EAN
- products_not_found: Products not found in local catalog
"""
stats = {
"total": len(shipments),
"imported": 0,
"updated": 0,
"skipped": 0,
"products_matched": 0,
"products_not_found": 0,
"eans_processed": set(),
"eans_matched": set(),
"eans_not_found": set(),
}
for shipment in shipments:
shipment_id = shipment.get("id")
if not shipment_id:
continue
# Check if order already exists
existing_order = self.get_order_by_shipment_id(vendor_id, shipment_id)
if existing_order:
# Check if we need to update (e.g., state changed)
if existing_order.letzshop_state != shipment.get("state"):
self.update_order_from_shipment(existing_order, shipment)
stats["updated"] += 1
else:
stats["skipped"] += 1
else:
# Create new order
self.create_order(vendor_id, shipment)
stats["imported"] += 1
# Process EANs for matching
if match_products:
inventory_units = shipment.get("inventoryUnits", [])
for unit in inventory_units:
variant = unit.get("variant", {}) or {}
trade_id = variant.get("tradeId") or {}
ean = trade_id.get("number")
if ean:
stats["eans_processed"].add(ean)
# Match EANs to local products
if match_products and stats["eans_processed"]:
matched, not_found = self._match_eans_to_products(
vendor_id, list(stats["eans_processed"])
)
stats["eans_matched"] = matched
stats["eans_not_found"] = not_found
stats["products_matched"] = len(matched)
stats["products_not_found"] = len(not_found)
# Convert sets to lists for JSON serialization
stats["eans_processed"] = list(stats["eans_processed"])
stats["eans_matched"] = list(stats["eans_matched"])
stats["eans_not_found"] = list(stats["eans_not_found"])
return stats
def _match_eans_to_products(
self,
vendor_id: int,
eans: list[str],
) -> tuple[set[str], set[str]]:
"""
Match EAN codes to local products.
Args:
vendor_id: Vendor ID to search products for.
eans: List of EAN codes to match.
Returns:
Tuple of (matched_eans, not_found_eans).
"""
if not eans:
return set(), set()
# Query products by GTIN for this vendor
products = (
self.db.query(Product)
.filter(
Product.vendor_id == vendor_id,
Product.gtin.in_(eans),
)
.all()
)
matched_eans = {p.gtin for p in products if p.gtin}
not_found_eans = set(eans) - matched_eans
logger.info(
f"EAN matching: {len(matched_eans)} matched, {len(not_found_eans)} not found"
)
return matched_eans, not_found_eans
def get_products_by_eans(
self,
vendor_id: int,
eans: list[str],
) -> dict[str, Product]:
"""
Get products by their EAN codes.
Args:
vendor_id: Vendor ID to search products for.
eans: List of EAN codes to search.
Returns:
Dict mapping EAN to Product.
"""
if not eans:
return {}
products = (
self.db.query(Product)
.filter(
Product.vendor_id == vendor_id,
Product.gtin.in_(eans),
)
.all()
)
return {p.gtin: p for p in products if p.gtin}
def get_historical_import_summary(
self,
vendor_id: int,
) -> dict[str, Any]:
"""
Get summary of historical order data for a vendor.
Returns:
Dict with summary statistics.
"""
# Count orders by state
order_counts = (
self.db.query(
LetzshopOrder.letzshop_state,
func.count(LetzshopOrder.id).label("count"),
)
.filter(LetzshopOrder.vendor_id == vendor_id)
.group_by(LetzshopOrder.letzshop_state)
.all()
)
# Count orders by locale
locale_counts = (
self.db.query(
LetzshopOrder.customer_locale,
func.count(LetzshopOrder.id).label("count"),
)
.filter(LetzshopOrder.vendor_id == vendor_id)
.group_by(LetzshopOrder.customer_locale)
.all()
)
# Count orders by country
country_counts = (
self.db.query(
LetzshopOrder.shipping_country_iso,
func.count(LetzshopOrder.id).label("count"),
)
.filter(LetzshopOrder.vendor_id == vendor_id)
.group_by(LetzshopOrder.shipping_country_iso)
.all()
)
# Total revenue
total_orders = (
self.db.query(func.count(LetzshopOrder.id))
.filter(LetzshopOrder.vendor_id == vendor_id)
.scalar()
or 0
)
# Unique customers
unique_customers = (
self.db.query(func.count(func.distinct(LetzshopOrder.customer_email)))
.filter(LetzshopOrder.vendor_id == vendor_id)
.scalar()
or 0
)
return {
"total_orders": total_orders,
"unique_customers": unique_customers,
"orders_by_state": {state: count for state, count in order_counts},
"orders_by_locale": {locale or "unknown": count for locale, count in locale_counts},
"orders_by_country": {country or "unknown": count for country, count in country_counts},
}

View File

@@ -1,21 +1,57 @@
{# app/templates/admin/partials/letzshop-orders-tab.html #}
{# Orders tab for admin Letzshop management #}
<!-- Header with Import Button -->
<!-- Header with Import Buttons -->
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Orders</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage Letzshop orders for this vendor</p>
</div>
<button
@click="importOrders()"
:disabled="!letzshopStatus.is_configured || importingOrders"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!importingOrders" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
<span x-show="importingOrders" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importingOrders ? 'Importing...' : 'Import Orders'"></span>
</button>
<div class="flex gap-2">
<button
@click="importHistoricalOrders()"
:disabled="!letzshopStatus.is_configured || importingHistorical"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
title="Import all historical confirmed orders"
>
<span x-show="!importingHistorical" x-html="$icon('archive', 'w-4 h-4 mr-2')"></span>
<span x-show="importingHistorical" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importingHistorical ? 'Importing...' : 'Import History'"></span>
</button>
<button
@click="importOrders()"
:disabled="!letzshopStatus.is_configured || importingOrders"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!importingOrders" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
<span x-show="importingOrders" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importingOrders ? 'Importing...' : 'Import New'"></span>
</button>
</div>
</div>
<!-- Historical Import Result -->
<div x-show="historicalImportResult" x-transition class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start justify-between">
<div class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 text-blue-500 mr-3 mt-0.5')"></span>
<div>
<h4 class="font-medium text-blue-800 dark:text-blue-200">Historical Import Complete</h4>
<div class="text-sm text-blue-700 dark:text-blue-300 mt-1">
<span x-text="historicalImportResult?.imported + ' imported'"></span> ·
<span x-text="historicalImportResult?.updated + ' updated'"></span> ·
<span x-text="historicalImportResult?.skipped + ' skipped'"></span>
</div>
<div x-show="historicalImportResult?.products_matched > 0 || historicalImportResult?.products_not_found > 0" class="text-sm text-blue-600 dark:text-blue-400 mt-1">
<span x-text="historicalImportResult?.products_matched + ' products matched by EAN'"></span> ·
<span x-text="historicalImportResult?.products_not_found + ' not found'"></span>
</div>
</div>
</div>
<button @click="historicalImportResult = null" class="text-blue-500 hover:text-blue-700">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</div>
<!-- Status Cards -->

View File

@@ -0,0 +1,517 @@
# Letzshop Order Import - Improvement Plan
## Current Status (2025-12-17)
### Schema Discovery Complete ✅
After running GraphQL introspection queries, we have identified all available fields.
### Available Fields Summary
| Data | GraphQL Path | Notes |
|------|-------------|-------|
| **EAN/GTIN** | `variant.tradeId.number` | The product barcode |
| **Trade ID Type** | `variant.tradeId.parser` | Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 |
| **Brand Name** | `product._brand { ... on Brand { name } }` | Union type requires fragment |
| **MPN** | `variant.mpn` | Manufacturer Part Number |
| **SKU** | `variant.sku` | Merchant's internal SKU |
| **Product Name** | `variant.product.name { en, fr, de }` | Translated names |
| **Price** | `variant.price` | Unit price |
| **Quantity** | Count of `inventoryUnits` | Each unit = 1 item |
| **Customer Language** | `order.locale` | Language for invoice (en, fr, de) |
| **Customer Country** | `order.shipAddress.country` | Country object |
### Key Findings
1. **EAN lives in `tradeId`** - Not a direct field on Variant, but nested in `tradeId.number`
2. **TradeIdParser enum values**: `gtin14`, `gtin13` (EAN-13), `gtin12` (UPC), `gtin8`, `isbn13`, `isbn10`
3. **Brand is a Union** - Must use `... on Brand { name }` fragment, also handles `BrandUnknown`
4. **No quantity field** - Each InventoryUnit represents 1 item; count units to get quantity
## Updated GraphQL Query
```graphql
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
iso
}
}
billAddress {
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
country {
name
iso
}
}
}
inventoryUnits {
id
state
variant {
id
sku
mpn
price
tradeId {
number
parser
}
product {
name { en fr de }
_brand {
... on Brand { name }
}
}
}
}
tracking {
code
provider
}
}
}
}
```
## Implementation Steps
### Step 1: Update GraphQL Queries ✅ DONE
Update in `app/services/letzshop/client_service.py`:
- `QUERY_SHIPMENTS_UNCONFIRMED`
- `QUERY_SHIPMENTS_CONFIRMED`
- `QUERY_SHIPMENT_BY_ID`
- `QUERY_SHIPMENTS_PAGINATED_TEMPLATE` ✅ (new - for historical import)
### Step 2: Update Order Service ✅ DONE
Updated `create_order()` and `update_order_from_shipment()` in `app/services/letzshop/order_service.py`:
- Extract `tradeId.number` as EAN ✅
- Store MPN if available ✅
- Store `locale` for invoice language ✅
- Store shipping/billing country ISO codes ✅
- Enrich inventory_units with EAN, MPN, SKU, product_name ✅
**Database changes:**
- Added `customer_locale` column to `LetzshopOrder`
- Added `shipping_country_iso` column to `LetzshopOrder`
- Added `billing_country_iso` column to `LetzshopOrder`
- Migration: `a9a86cef6cca_add_letzshop_order_locale_and_country_.py`
### Step 3: Match Products by EAN ✅ DONE (Basic)
When importing orders:
- Use `tradeId.number` (EAN) to find matching local product ✅
- `_match_eans_to_products()` function added ✅
- Returns match statistics (products_matched, products_not_found) ✅
**TODO for later:**
- ⬜ Decrease stock for matched product (needs careful implementation)
- ⬜ Show match status in order detail view
### Step 4: Update Frontend ✅ DONE (Historical Import)
- Added "Import History" button to Orders tab ✅
- Added historical import result display ✅
- Added `importHistoricalOrders()` JavaScript function ✅
**TODO for later:**
- ⬜ Show product details in individual order view (EAN, MPN, SKU, match status)
### Step 5: Historical Import Feature ✅ DONE
Import all confirmed orders for:
- Sales analytics (how many products sold)
- Customer records
- Historical data
**Implementation:**
- Pagination support with `get_all_shipments_paginated()`
- Deduplication by `letzshop_order_id`
- EAN matching during import ✅
- Progress callback for large imports ✅
**Endpoints Added:**
- `POST /api/v1/admin/letzshop/vendors/{id}/import-history` - Import historical orders
- `GET /api/v1/admin/letzshop/vendors/{id}/import-summary` - Get import statistics
**Frontend:**
- "Import History" button in Orders tab
- Result display showing imported/updated/skipped counts
**Tests:**
- Unit tests in `tests/unit/services/test_letzshop_service.py`
- Manual test script `scripts/test_historical_import.py`
## Test Results (2025-12-17)
### Query Test: PASSED ✅
```
Example shipment:
Shipment #: H43748338602
Order #: R702236251
Customer: miriana.leal@letzshop.lu
Locale: fr <<<< LANGUAGE
Total: 32.88 EUR
Ship to: Miriana Leal Ferreira
City: 1468 Luxembourg
Country: LU
Items (1):
- Pocket POP! Keychains: Marvel Avengers Infinity War - Iron Spider
SKU: 00889698273022
MPN: None
EAN: 00889698273022 (gtin14) <<<< BARCODE
Price: 5.88 EUR
```
### Known Issues / Letzshop API Bugs
#### Bug 1: `_brand` field causes server error
- **Error**: `NoMethodError: undefined method 'demodulize' for nil`
- **Trigger**: Querying `_brand { ... on Brand { name } }` on some products
- **Workaround**: Removed `_brand` from queries
- **Status**: To report to Letzshop
#### Bug 2: `tracking` field causes server error (ALL queries)
- **Error**: `NoMethodError: undefined method 'demodulize' for nil`
- **Trigger**: Including `tracking { code provider }` in ANY shipment query
- **Tested and FAILS on**:
- Paginated queries: `shipments(state: confirmed, first: 10) { nodes { tracking { code provider } } }`
- Non-paginated queries: `shipments(state: confirmed) { nodes { tracking { code provider } } }`
- Single shipment queries: Also fails (Letzshop doesn't support `node(id:)` interface)
- **Impact**: Cannot retrieve tracking numbers and carrier info at all
- **Workaround**: None - tracking info is currently unavailable via API
- **Status**: **CRITICAL - Must report to Letzshop**
- **Date discovered**: 2025-12-17
**Note**: Letzshop automatically creates tracking when orders are confirmed. The carrier picks up parcels. But we cannot retrieve this info due to the API bug.
#### Bug 3: Product table missing `gtin` field ✅ FIXED
- **Error**: `type object 'Product' has no attribute 'gtin'`
- **Cause**: `gtin` field only existed on `MarketplaceProduct` (staging table), not on `Product` (operational table)
- **Date discovered**: 2025-12-17
- **Date fixed**: 2025-12-18
- **Fix applied**:
1. Migration `cb88bc9b5f86_add_gtin_columns_to_product_table.py` adds:
- `gtin` (String(50)) - the barcode number
- `gtin_type` (String(20)) - the format type (gtin13, gtin14, etc.)
- Indexes: `idx_product_gtin`, `idx_product_vendor_gtin`
2. `models/database/product.py` updated with new columns
3. `_match_eans_to_products()` now queries `Product.gtin`
4. `get_products_by_eans()` now returns products by EAN lookup
- **Status**: COMPLETE
**GTIN Types Reference:**
| Type | Digits | Common Name | Region/Use |
|------|--------|-------------|------------|
| gtin13 | 13 | EAN-13 | Europe (most common) |
| gtin12 | 12 | UPC-A | North America |
| gtin14 | 14 | ITF-14 | Logistics/cases |
| gtin8 | 8 | EAN-8 | Small items |
| isbn13 | 13 | ISBN-13 | Books |
| isbn10 | 10 | ISBN-10 | Books (legacy) |
Letzshop API returns:
- `tradeId.number` → store in `gtin`
- `tradeId.parser` → store in `gtin_type`
**Letzshop Shipment States (from official docs):**
| Letzshop State | Our sync_status | Description |
|----------------|-----------------|-------------|
| `unconfirmed` | `pending` | New order, needs vendor confirmation |
| `confirmed` | `confirmed` | At least one product confirmed |
| `declined` | `rejected` | All products rejected |
Note: There is no "shipped" state in Letzshop. Shipping is tracked via the `tracking` field (code + provider), not as a state change.
---
## Historical Confirmed Orders Import
### Purpose
Import all historical confirmed orders from Letzshop to:
1. **Sales Analytics** - Track total products sold, revenue by product/category
2. **Customer Records** - Build customer database with order history
3. **Inventory Reconciliation** - Understand what was sold to reconcile stock
### Implementation Plan
#### 1. Add "Import Historical Orders" Feature
- New endpoint: `POST /api/v1/admin/letzshop/vendors/{id}/import-history`
- Parameters:
- `state`: confirmed/shipped/delivered (default: confirmed)
- `since`: Optional date filter (import orders after this date)
- `dry_run`: Preview without saving
#### 2. Pagination Support
Letzshop likely returns paginated results. Need to handle:
```graphql
query {
shipments(state: confirmed, first: 50, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes { ... }
}
}
```
#### 3. Deduplication
- Check if order already exists by `letzshop_order_id` before inserting
- Update existing orders if data changed
#### 4. EAN Matching & Stock Adjustment
When importing historical orders:
- Match `tradeId.number` (EAN) to local products
- Calculate total quantity sold per product
- Option to adjust inventory based on historical sales
#### 5. Customer Database
Extract and store customer data:
- Email (unique identifier)
- Name (from shipping address)
- Preferred language (from `order.locale`)
- Order count, total spent
#### 6. UI: Historical Import Page
Admin interface to:
- Trigger historical import
- View import progress
- See summary: X orders imported, Y customers added, Z products matched
### Data Flow
```
Letzshop API (confirmed shipments)
┌───────────────────────┐
│ Import Service │
│ - Fetch all pages │
│ - Deduplicate │
│ - Match EAN to SKU │
└───────────────────────┘
┌───────────────────────┐
│ Database │
│ - letzshop_orders │
│ - customers │
│ - inventory updates │
└───────────────────────┘
┌───────────────────────┐
│ Analytics Dashboard │
│ - Sales by product │
│ - Revenue over time │
│ - Customer insights │
└───────────────────────┘
```
---
## Schema Reference
### Variant Fields
```
baseAmount: String
baseAmountProduct: String
baseUnit: String
countOnHand: Int
id: ID!
images: [Image]!
inPresale: Boolean!
isMaster: Boolean!
mpn: String
price: Float!
priceCrossed: Float
pricePerUnit: Float
product: Product!
properties: [Property]!
releaseAt: Iso8601Time
sku: String
tradeId: TradeId
uniqueId: String
url: String!
```
### TradeId Fields
```
isRestricted: Boolean
number: String! # <-- THE EAN/GTIN
parser: TradeIdParser! # <-- Format identifier
```
### TradeIdParser Enum
```
gtin14 - GTIN-14 (14 digits)
gtin13 - GTIN-13 / EAN-13 (13 digits, most common in Europe)
gtin12 - GTIN-12 / UPC-A (12 digits, common in North America)
gtin8 - GTIN-8 / EAN-8 (8 digits)
isbn13 - ISBN-13 (books)
isbn10 - ISBN-10 (books)
```
### Brand (via BrandUnion)
```
BrandUnion = Brand | BrandUnknown
Brand fields:
id: ID!
name: String!
identifier: String!
descriptor: String
logo: Attachment
url: String!
```
### InventoryUnit Fields
```
id: ID!
price: Float!
state: String!
taxRate: Float!
uniqueId: String
variant: Variant
```
## Reference: Letzshop Frontend Shows
From the Letzshop merchant interface:
- Order number: R532332163
- Shipment number: H74683403433
- Product: "Pop! Rocks: DJ Khaled - DJ Khaled #237"
- Brand: Funko
- Internal merchant number: MH-FU-56757
- Price: 16,95 €
- Quantity: 1
- Shipping: 2,99 €
- Total: 19,94 €
---
## Completed (2025-12-18)
### Order Stats Fix ✅
- **Issue**: Order status cards (Pending, Confirmed, etc.) were showing incorrect counts
- **Cause**: Stats were calculated client-side from only the visible page of orders
- **Fix**:
1. Added `get_order_stats()` method to `LetzshopOrderService`
2. Added `LetzshopOrderStats` schema with pending/confirmed/rejected/shipped counts
3. API now returns `stats` field with counts for ALL orders
4. JavaScript uses server-side stats instead of client-side calculation
- **Status**: COMPLETE
### Tracking Investigation ✅
- **Issue**: Letzshop API bug prevents querying tracking field
- **Added**: `--tracking` option to `letzshop_introspect.py` to investigate workarounds
- **Findings**: Bug is on Letzshop's side, no client-side workaround possible
- **Recommendation**: Store tracking info locally after setting via API
---
## Next Steps (TODO)
### Priority 1: Historical Import Progress Bar
Add real-time progress feedback for historical import (currently no visibility into import progress).
**Requirements:**
- Show progress indicator while import is running
- Display current page being fetched (e.g., "Fetching page 3 of 12...")
- Show running count of orders imported/updated
- Prevent user from thinking the process is stuck
**Implementation options:**
1. **Polling approach**: Frontend polls a status endpoint every few seconds
2. **Server-Sent Events (SSE)**: Real-time updates pushed to frontend
3. **WebSocket**: Bi-directional real-time communication
**Backend changes needed:**
- Store import progress in database or cache (Redis)
- Add endpoint `GET /api/v1/admin/letzshop/vendors/{id}/import-progress`
- Update `import_historical_shipments()` to report progress
**Frontend changes needed:**
- Progress bar component in Orders tab
- Polling/SSE logic to fetch progress updates
- Disable "Import History" button while import is in progress
### Priority 2: Stock Management
When an order is confirmed/imported:
1. Match EAN from order to local product catalog
2. Decrease stock quantity for matched products
3. Handle cases where product not found (alert/log)
**Considerations:**
- Should stock decrease happen on import or only on confirmation?
- Need rollback mechanism if order is rejected
- Handle partial matches (some items found, some not)
### Priority 2: Order Detail View Enhancement
Improve the order detail modal to show:
- Product details (name, EAN, MPN, SKU)
- Match status per line item (found/not found in catalog)
- Link to local product if matched
### Priority 3: Invoice Generation
Use `customer_locale` to generate invoices in customer's language:
- Invoice template with multi-language support
- PDF generation
### Priority 4: Analytics Dashboard
Build sales analytics based on imported orders:
- Sales by product
- Sales by time period
- Customer statistics
- Revenue breakdown
---
## Files Modified (2025-12-16 to 2025-12-18)
| File | Changes |
|------|---------|
| `app/services/letzshop/client_service.py` | Added paginated query, updated all queries with EAN/locale/country |
| `app/services/letzshop/order_service.py` | Added historical import, EAN matching, summary endpoint, order stats |
| `models/database/letzshop.py` | Added locale and country columns |
| `models/database/product.py` | Added `gtin` and `gtin_type` columns for EAN matching |
| `models/schema/letzshop.py` | Added `LetzshopOrderStats` schema, stats to order list response |
| `app/api/v1/admin/letzshop.py` | Added import-history and import-summary endpoints, stats in orders response |
| `app/templates/admin/partials/letzshop-orders-tab.html` | Added Import History button and result display |
| `static/admin/js/marketplace-letzshop.js` | Added importHistoricalOrders(), server-side stats |
| `tests/unit/services/test_letzshop_service.py` | Added tests for new functionality |
| `scripts/test_historical_import.py` | Manual test script for historical import |
| `scripts/letzshop_introspect.py` | GraphQL schema introspection tool, tracking workaround tests |
| `alembic/versions/a9a86cef6cca_*.py` | Migration for locale/country columns |
| `alembic/versions/cb88bc9b5f86_*.py` | Migration for gtin columns on Product table |

View File

@@ -94,6 +94,13 @@ class LetzshopOrder(Base, TimestampMixin):
) # Store as string to preserve format
currency = Column(String(10), default="EUR")
# Customer preferences (for invoicing)
customer_locale = Column(String(10), nullable=True) # en, fr, de
# Shipping/billing country
shipping_country_iso = Column(String(5), nullable=True) # LU, DE, FR, etc.
billing_country_iso = Column(String(5), nullable=True)
# Raw data storage (for debugging/auditing)
raw_order_data = Column(JSON, nullable=True)

View File

@@ -45,6 +45,12 @@ class Product(Base, TimestampMixin):
# === VENDOR REFERENCE ===
vendor_sku = Column(String, index=True) # Vendor's internal SKU
# === PRODUCT IDENTIFIERS ===
# GTIN (Global Trade Item Number) - barcode for EAN matching with orders
# Populated from MarketplaceProduct.gtin during product import
gtin = Column(String(50), index=True) # EAN/UPC barcode number
gtin_type = Column(String(20)) # Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10
# === OVERRIDABLE FIELDS (NULL = inherit from marketplace_product) ===
# Pricing
price = Column(Float)
@@ -209,14 +215,37 @@ class Product(Base, TimestampMixin):
# === INVENTORY PROPERTIES ===
# Constant for unlimited inventory (digital products)
UNLIMITED_INVENTORY = 999999
@property
def has_unlimited_inventory(self) -> bool:
"""Check if product has unlimited inventory.
Digital products have unlimited inventory by default.
They don't require physical stock tracking.
"""
return self.is_digital
@property
def total_inventory(self) -> int:
"""Calculate total inventory across all locations."""
"""Calculate total inventory across all locations.
Digital products return unlimited inventory.
"""
if self.has_unlimited_inventory:
return self.UNLIMITED_INVENTORY
return sum(inv.quantity for inv in self.inventory_entries)
@property
def available_inventory(self) -> int:
"""Calculate available inventory (total - reserved)."""
"""Calculate available inventory (total - reserved).
Digital products return unlimited inventory since they
don't have physical stock constraints.
"""
if self.has_unlimited_inventory:
return self.UNLIMITED_INVENTORY
return sum(inv.available_quantity for inv in self.inventory_entries)
# === OVERRIDE INFO METHOD ===

View File

@@ -130,6 +130,15 @@ class LetzshopOrderDetailResponse(LetzshopOrderResponse):
raw_order_data: dict[str, Any] | None = None
class LetzshopOrderStats(BaseModel):
"""Schema for order statistics by status."""
pending: int = 0
confirmed: int = 0
rejected: int = 0
shipped: int = 0
class LetzshopOrderListResponse(BaseModel):
"""Schema for paginated Letzshop order list."""
@@ -137,6 +146,7 @@ class LetzshopOrderListResponse(BaseModel):
total: int
skip: int
limit: int
stats: LetzshopOrderStats | None = None # Order counts by sync_status
# ============================================================================

View File

@@ -0,0 +1,725 @@
#!/usr/bin/env python3
"""
Letzshop GraphQL Schema Introspection Script.
Discovers available fields on Variant, Product, and BrandUnion types
to find EAN/GTIN/barcode identifiers.
Usage:
python scripts/letzshop_introspect.py YOUR_API_KEY
"""
import sys
import requests
import json
ENDPOINT = "https://letzshop.lu/graphql"
# Introspection queries
QUERIES = {
"Variant": """
{
__type(name: "Variant") {
name
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"Product": """
{
__type(name: "Product") {
name
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"BrandUnion": """
{
__type(name: "BrandUnion") {
name
kind
possibleTypes {
name
}
}
}
""",
"Brand": """
{
__type(name: "Brand") {
name
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"InventoryUnit": """
{
__type(name: "InventoryUnit") {
name
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"TradeId": """
{
__type(name: "TradeId") {
name
kind
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"TradeIdParser": """
{
__type(name: "TradeIdParser") {
name
kind
enumValues { name }
}
}
""",
"Order": """
{
__type(name: "Order") {
name
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"User": """
{
__type(name: "User") {
name
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"Address": """
{
__type(name: "Address") {
name
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"Shipment": """
{
__type(name: "Shipment") {
name
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"Tracking": """
{
__type(name: "Tracking") {
name
kind
fields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
""",
"ShipmentState": """
{
__type(name: "ShipmentState") {
name
kind
enumValues { name description }
}
}
""",
}
def run_query(api_key: str, query: str) -> dict:
"""Execute a GraphQL query."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
response = requests.post(
ENDPOINT,
json={"query": query},
headers=headers,
timeout=30,
)
return response.json()
def format_type(type_info: dict) -> str:
"""Format a GraphQL type for display."""
if not type_info:
return "?"
kind = type_info.get("kind", "")
name = type_info.get("name", "")
of_type = type_info.get("ofType")
if kind == "NON_NULL":
return f"{format_type(of_type)}!"
elif kind == "LIST":
return f"[{format_type(of_type)}]"
else:
return name or kind
def print_fields(type_data: dict, highlight_terms: list[str] = None):
"""Print fields from introspection result."""
if not type_data:
print(" (no data)")
return
highlight_terms = highlight_terms or []
fields = type_data.get("fields") or []
if not fields:
# Might be a union type
possible_types = type_data.get("possibleTypes")
if possible_types:
print(f" Union of: {', '.join(t['name'] for t in possible_types)}")
return
# Might be an enum
enum_values = type_data.get("enumValues")
if enum_values:
print(f" Enum values: {', '.join(v['name'] for v in enum_values)}")
return
return
for field in sorted(fields, key=lambda f: f["name"]):
name = field["name"]
type_str = format_type(field.get("type", {}))
# Highlight interesting fields
marker = ""
name_lower = name.lower()
if any(term in name_lower for term in highlight_terms):
marker = " <<<< LOOK!"
print(f" {name}: {type_str}{marker}")
TEST_SHIPMENT_QUERY_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
}
}
}
}
"""
TEST_SHIPMENT_QUERY_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
}
}
}
}
"""
TEST_SHIPMENT_QUERY_SIMPLE = """
query {{
shipments(state: {state}) {{
nodes {{
id
number
state
order {{
id
number
email
total
locale
shipAddress {{
firstName
lastName
city
zipCode
country {{
iso
}}
}}
}}
inventoryUnits {{
id
state
variant {{
id
sku
mpn
price
tradeId {{
number
parser
}}
product {{
name {{
en
fr
de
}}
}}
}}
}}
}}
}}
}}
"""
def test_shipment_query(api_key: str, state: str = "unconfirmed"):
"""Test the full shipment query with all new fields."""
print("\n" + "=" * 60)
print(f"Testing Full Shipment Query (state: {state})")
print("=" * 60)
# Try simple query first (without _brand which may cause issues)
query = TEST_SHIPMENT_QUERY_SIMPLE.format(state=state)
try:
result = run_query(api_key, query)
if "errors" in result:
print(f"\n❌ QUERY FAILED!")
print(f"Errors: {json.dumps(result['errors'], indent=2)}")
return False
shipments = result.get("data", {}).get("shipments", {}).get("nodes", [])
print(f"\n✅ Query successful! Found {len(shipments)} unconfirmed shipment(s)")
if shipments:
# Show first shipment as example
shipment = shipments[0]
order = shipment.get("order", {})
units = shipment.get("inventoryUnits", [])
print(f"\nExample shipment:")
print(f" Shipment #: {shipment.get('number')}")
print(f" Order #: {order.get('number')}")
print(f" Customer: {order.get('email')}")
print(f" Locale: {order.get('locale')} <<<< LANGUAGE")
print(f" Total: {order.get('total')} EUR")
ship_addr = order.get("shipAddress", {})
country = ship_addr.get("country", {})
print(f"\n Ship to: {ship_addr.get('firstName')} {ship_addr.get('lastName')}")
print(f" City: {ship_addr.get('zipCode')} {ship_addr.get('city')}")
print(f" Country: {country.get('iso')}")
print(f"\n Items ({len(units)}):")
for unit in units[:3]: # Show first 3
variant = unit.get("variant", {})
product = variant.get("product", {})
trade_id = variant.get("tradeId") or {}
name = product.get("name", {})
product_name = name.get("en") or name.get("fr") or name.get("de") or "?"
print(f"\n - {product_name}")
print(f" SKU: {variant.get('sku')}")
print(f" MPN: {variant.get('mpn')}")
print(f" EAN: {trade_id.get('number')} ({trade_id.get('parser')}) <<<< BARCODE")
print(f" Price: {variant.get('price')} EUR")
if len(units) > 3:
print(f"\n ... and {len(units) - 3} more items")
return True
except Exception as e:
print(f"\n❌ ERROR: {e}")
return False
def main():
if len(sys.argv) < 2:
print("Usage: python scripts/letzshop_introspect.py YOUR_API_KEY [OPTIONS]")
print("\nThis script queries the Letzshop GraphQL schema to discover")
print("available fields for EAN, GTIN, barcode, brand, etc.")
print("\nOptions:")
print(" --test Run the full shipment query to verify it works")
print(" --confirmed Test with confirmed shipments (default: unconfirmed)")
print(" --tracking Test tracking API workarounds (investigate bug)")
sys.exit(1)
api_key = sys.argv[1]
run_test = "--test" in sys.argv
use_confirmed = "--confirmed" in sys.argv
# Terms to highlight in output
highlight = ["ean", "gtin", "barcode", "brand", "mpn", "sku", "code", "identifier", "lang", "locale", "country"]
print("=" * 60)
print("Letzshop GraphQL Schema Introspection")
print("=" * 60)
print(f"\nLooking for fields containing: {', '.join(highlight)}\n")
for type_name, query in QUERIES.items():
print(f"\n{'='*60}")
print(f"Type: {type_name}")
print("=" * 60)
try:
result = run_query(api_key, query)
if "errors" in result:
print(f" ERROR: {result['errors']}")
continue
type_data = result.get("data", {}).get("__type")
if type_data:
print_fields(type_data, highlight)
else:
print(" (type not found)")
except Exception as e:
print(f" ERROR: {e}")
print("\n" + "=" * 60)
print("Done! Look for '<<<< LOOK!' markers for relevant fields.")
print("=" * 60)
# Run the test query if requested
if run_test:
state = "confirmed" if use_confirmed else "unconfirmed"
test_shipment_query(api_key, state)
# Test tracking workaround if requested
if "--tracking" in sys.argv:
test_tracking_workaround(api_key)
def test_tracking_workaround(api_key: str):
"""
Test various approaches to get tracking information.
Known issue: The `tracking` field causes a server error.
This function tests alternative approaches.
"""
print("\n" + "=" * 60)
print("Testing Tracking Workarounds")
print("=" * 60)
# Test 1: Query shipment without tracking field
print("\n1. Shipment query WITHOUT tracking field:")
query_no_tracking = """
query {
shipments(state: confirmed, first: 1) {
nodes {
id
number
state
}
}
}
"""
try:
result = run_query(api_key, query_no_tracking)
if "errors" in result:
print(f" ❌ FAILED: {result['errors']}")
else:
shipments = result.get("data", {}).get("shipments", {}).get("nodes", [])
print(f" ✅ SUCCESS: Found {len(shipments)} shipments")
if shipments:
print(f" Sample: {shipments[0]}")
except Exception as e:
print(f" ❌ ERROR: {e}")
# Test 2: Query shipment WITH tracking field (expected to fail)
print("\n2. Shipment query WITH tracking field (expected to fail):")
query_with_tracking = """
query {
shipments(state: confirmed, first: 1) {
nodes {
id
number
state
tracking {
code
provider
}
}
}
}
"""
try:
result = run_query(api_key, query_with_tracking)
if "errors" in result:
print(f" ❌ FAILED (expected): {result['errors'][0].get('message', 'Unknown error')}")
else:
print(f" ✅ SUCCESS (unexpected!): {result}")
except Exception as e:
print(f" ❌ ERROR: {e}")
# Test 3: Try to query Tracking type directly via introspection
print("\n3. Introspecting Tracking type:")
try:
result = run_query(api_key, QUERIES.get("Tracking", ""))
if "errors" in result:
print(f" ❌ FAILED: {result['errors']}")
else:
type_data = result.get("data", {}).get("__type")
if type_data:
print(" ✅ Tracking type found. Fields:")
print_fields(type_data, ["code", "provider", "carrier", "number"])
else:
print(" ⚠️ Tracking type not found in schema")
except Exception as e:
print(f" ❌ ERROR: {e}")
# Test 4: Check if there are alternative tracking-related fields on Shipment
print("\n4. Looking for alternative tracking fields on Shipment:")
try:
result = run_query(api_key, QUERIES.get("Shipment", ""))
if "errors" in result:
print(f" ❌ FAILED: {result['errors']}")
else:
type_data = result.get("data", {}).get("__type")
if type_data:
fields = type_data.get("fields", [])
tracking_related = [
f for f in fields
if any(term in f["name"].lower() for term in
["track", "carrier", "ship", "deliver", "dispatch", "fulfil"])
]
if tracking_related:
print(" Found potential tracking-related fields:")
for f in tracking_related:
print(f" - {f['name']}: {format_type(f.get('type', {}))}")
else:
print(" No alternative tracking fields found")
except Exception as e:
print(f" ❌ ERROR: {e}")
print("\n" + "=" * 60)
print("Tracking Workaround Summary:")
print("-" * 60)
print("""
Current status: Letzshop API has a bug where querying the 'tracking'
field causes a server error (NoMethodError: undefined method 'demodulize').
Workaround options:
1. Wait for Letzshop to fix the bug
2. Query shipments without tracking field, then retrieve tracking
info via a separate mechanism (e.g., Letzshop merchant portal)
3. Check if tracking info is available via webhook notifications
4. Store tracking info locally after setting it via confirmInventoryUnit
Recommendation: For now, skip querying tracking and rely on local
tracking data after confirmation via API.
""")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,723 @@
#!/usr/bin/env python3
"""
Test script for Letzshop historical order import.
Usage:
python scripts/test_historical_import.py YOUR_API_KEY [--max-pages 2] [--debug]
This script tests the historical import functionality by:
1. Testing different query variations to find what works
2. Showing what would be imported
3. Debugging GraphQL errors
"""
import argparse
import sys
from pathlib import Path
import requests
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
# Different query variations to test
QUERIES = {
"minimal": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
state
}
}
}
""",
"with_order": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
state
order {
id
number
email
total
locale
}
}
}
}
""",
"with_address": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
state
order {
id
number
email
shipAddress {
firstName
lastName
city
zipCode
}
}
}
}
}
""",
"with_country": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
order {
id
shipAddress {
country {
iso
}
}
}
}
}
}
""",
"with_inventory": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
inventoryUnits {
id
state
variant {
id
sku
price
}
}
}
}
}
""",
"with_tradeid": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
inventoryUnits {
id
variant {
id
sku
tradeId {
number
parser
}
}
}
}
}
}
""",
"with_product": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
inventoryUnits {
id
variant {
id
product {
name {
en
fr
de
}
}
}
}
}
}
}
""",
"with_tracking_paginated": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
tracking {
code
provider
}
}
}
}
""",
"tracking_no_pagination": """
query {
shipments(state: confirmed) {
nodes {
id
number
tracking {
code
provider
}
}
}
}
""",
"single_shipment_with_tracking": """
query GetShipment($id: ID!) {
node(id: $id) {
... on Shipment {
id
number
tracking {
code
provider
}
}
}
}
""",
"with_mpn": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
inventoryUnits {
id
variant {
id
sku
mpn
}
}
}
}
}
""",
"with_completedAt": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
order {
id
completedAt
}
}
}
}
""",
"with_full_address": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
order {
id
shipAddress {
firstName
lastName
company
streetName
streetNumber
city
zipCode
phone
}
}
}
}
}
""",
"combined_no_tracking": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, 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
}
}
}
inventoryUnits {
id
state
variant {
id
sku
mpn
price
tradeId {
number
parser
}
product {
name {
en
fr
de
}
}
}
}
}
}
}
""",
"full": """
query GetShipmentsPaginated($first: Int!, $after: String) {
shipments(state: confirmed, 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
}
}
}
inventoryUnits {
id
state
variant {
id
sku
mpn
price
tradeId {
number
parser
}
product {
name {
en
fr
de
}
}
}
}
tracking {
code
provider
}
}
}
}
""",
}
def test_query(api_key: str, query_name: str, query: str, page_size: int = 5, shipment_id: str = None) -> bool:
"""Test a single query and return True if it works."""
print(f"\n Testing '{query_name}'... ", end="", flush=True)
try:
# Check if query uses pagination variables
uses_pagination = "$first" in query
uses_node_id = "$id" in query
payload = {"query": query}
if uses_pagination:
payload["variables"] = {"first": page_size, "after": None}
elif uses_node_id:
if not shipment_id:
print("SKIPPED - needs shipment ID")
return None # Return None to indicate skipped
payload["variables"] = {"id": shipment_id}
response = requests.post(
"https://letzshop.lu/graphql",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=30,
)
data = response.json()
if "errors" in data and data["errors"]:
error_msg = data["errors"][0].get("message", "Unknown error")
print(f"FAILED - {error_msg[:50]}")
return False
# Handle different response structures
if uses_node_id:
node = data.get("data", {}).get("node")
if node:
tracking = node.get("tracking")
print(f"OK - tracking: {tracking}")
else:
print("OK - no node returned")
return True
else:
shipments = data.get("data", {}).get("shipments", {}).get("nodes", [])
print(f"OK - {len(shipments)} shipments")
return True
except Exception as e:
print(f"ERROR - {e}")
return False
def get_first_shipment_id(api_key: str) -> str | None:
"""Get the ID of the first confirmed shipment for testing."""
query = """
query {
shipments(state: confirmed) {
nodes {
id
}
}
}
"""
try:
response = requests.post(
"https://letzshop.lu/graphql",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={"query": query},
timeout=30,
)
data = response.json()
nodes = data.get("data", {}).get("shipments", {}).get("nodes", [])
if nodes:
return nodes[0].get("id")
except Exception:
pass
return None
def main():
parser = argparse.ArgumentParser(description="Test Letzshop historical import")
parser.add_argument("api_key", help="Letzshop API key")
parser.add_argument(
"--state",
default="confirmed",
choices=["confirmed", "unconfirmed"],
help="Shipment state to fetch",
)
parser.add_argument(
"--max-pages",
type=int,
default=2,
help="Maximum pages to fetch (default: 2 for testing)",
)
parser.add_argument(
"--page-size",
type=int,
default=10,
help="Page size (default: 10 for testing)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Run query diagnostics to find problematic fields",
)
parser.add_argument(
"--query",
choices=list(QUERIES.keys()),
help="Test a specific query variation",
)
args = parser.parse_args()
print("=" * 60)
print("Letzshop Historical Import Test")
print("=" * 60)
# Debug mode - test all query variations
if args.debug:
print("\nRunning query diagnostics...")
print("Testing different query variations to find what works:")
# Get a shipment ID for single-shipment tests
shipment_id = get_first_shipment_id(args.api_key)
if shipment_id:
print(f"\n Got shipment ID for testing: {shipment_id[:20]}...")
results = {}
for name, query in QUERIES.items():
results[name] = test_query(args.api_key, name, query, args.page_size, shipment_id)
print("\n" + "=" * 60)
print("Results Summary:")
print("=" * 60)
for name, success in results.items():
if success is None:
status = "⏭️ SKIPPED"
elif success:
status = "✅ WORKS"
else:
status = "❌ FAILS"
print(f" {name}: {status}")
# Find the last working query
working = [name for name, success in results.items() if success is True]
failing = [name for name, success in results.items() if success is False]
if failing:
print(f"\n⚠️ Problem detected!")
print(f" Working queries: {', '.join(working) if working else 'none'}")
print(f" Failing queries: {', '.join(failing)}")
if working:
print(f"\n The issue is likely in fields added after '{working[-1]}'")
else:
print("\n✅ All queries work! The issue may be elsewhere.")
return
# Test specific query
if args.query:
query = QUERIES[args.query]
print(f"\nTesting query: {args.query}")
test_query(args.api_key, args.query, query, args.page_size)
return
print(f"\nState: {args.state}")
print(f"Max pages: {args.max_pages}")
print(f"Page size: {args.page_size}")
# Progress callback
def progress(page, total):
print(f" Page {page}: {total} shipments fetched so far")
# Import here to avoid issues if just doing debug
from app.services.letzshop.client_service import LetzshopClient
# Create client and fetch
with LetzshopClient(api_key=args.api_key) as client:
print(f"\n{'='*60}")
print("Fetching shipments with pagination...")
print("=" * 60)
shipments = client.get_all_shipments_paginated(
state=args.state,
page_size=args.page_size,
max_pages=args.max_pages,
progress_callback=progress,
)
print(f"\n✅ Fetched {len(shipments)} shipments total")
if not shipments:
print("\nNo shipments found.")
return
# Analyze the data
print(f"\n{'='*60}")
print("Analysis")
print("=" * 60)
# Collect statistics
locales = {}
countries = {}
eans = set()
customers = set()
total_items = 0
for shipment in shipments:
order = shipment.get("order", {})
# Locale
locale = order.get("locale", "unknown")
locales[locale] = locales.get(locale, 0) + 1
# Country
ship_addr = order.get("shipAddress", {}) or {}
country = ship_addr.get("country", {}) or {}
country_iso = country.get("iso", "unknown")
countries[country_iso] = countries.get(country_iso, 0) + 1
# Customer
email = order.get("email")
if email:
customers.add(email)
# Items
units = shipment.get("inventoryUnits", [])
total_items += len(units)
for unit in units:
variant = unit.get("variant", {}) or {}
trade_id = variant.get("tradeId") or {}
ean = trade_id.get("number")
if ean:
eans.add(ean)
print(f"\nOrders: {len(shipments)}")
print(f"Unique customers: {len(customers)}")
print(f"Total items: {total_items}")
print(f"Unique EANs: {len(eans)}")
print(f"\nOrders by locale:")
for locale, count in sorted(locales.items(), key=lambda x: -x[1]):
print(f" {locale}: {count}")
print(f"\nOrders by country:")
for country, count in sorted(countries.items(), key=lambda x: -x[1]):
print(f" {country}: {count}")
# Show sample shipment
print(f"\n{'='*60}")
print("Sample Shipment")
print("=" * 60)
sample = shipments[0]
order = sample.get("order", {})
ship_addr = order.get("shipAddress", {}) or {}
units = sample.get("inventoryUnits", [])
print(f"\nShipment #: {sample.get('number')}")
print(f"Order #: {order.get('number')}")
print(f"Customer: {order.get('email')}")
print(f"Locale: {order.get('locale')}")
print(f"Total: {order.get('total')} EUR")
print(f"Ship to: {ship_addr.get('firstName')} {ship_addr.get('lastName')}")
print(f"City: {ship_addr.get('zipCode')} {ship_addr.get('city')}")
print(f"\nItems ({len(units)}):")
for unit in units[:3]:
variant = unit.get("variant", {}) or {}
product = variant.get("product", {}) or {}
trade_id = variant.get("tradeId") or {}
name = product.get("name", {})
product_name = name.get("en") or name.get("fr") or name.get("de") or "?"
print(f"\n - {product_name}")
print(f" EAN: {trade_id.get('number')} ({trade_id.get('parser')})")
print(f" SKU: {variant.get('sku')}")
print(f" Price: {variant.get('price')} EUR")
# Show EANs for matching test
print(f"\n{'='*60}")
print(f"Sample EANs ({min(10, len(eans))} of {len(eans)})")
print("=" * 60)
for ean in list(eans)[:10]:
print(f" {ean}")
print(f"\n{'='*60}")
print("Test Complete!")
print("=" * 60)
print("\nTo import these orders, use the API endpoint:")
print(" POST /api/v1/admin/letzshop/vendors/{vendor_id}/import-history")
print("\nOr run via curl:")
print(' curl -X POST "http://localhost:8000/api/v1/admin/letzshop/vendors/1/import-history?state=confirmed"')
if __name__ == "__main__":
main()

View File

@@ -27,6 +27,7 @@ function adminMarketplaceLetzshop() {
importing: false,
exporting: false,
importingOrders: false,
importingHistorical: false,
loadingOrders: false,
loadingJobs: false,
savingCredentials: false,
@@ -34,6 +35,9 @@ function adminMarketplaceLetzshop() {
testingConnection: false,
submittingTracking: false,
// Historical import result
historicalImportResult: null,
// Messages
error: '',
successMessage: '',
@@ -394,8 +398,13 @@ function adminMarketplaceLetzshop() {
this.orders = response.orders || [];
this.totalOrders = response.total || 0;
// Update order stats
this.updateOrderStats();
// Use server-side stats (counts all orders, not just visible page)
if (response.stats) {
this.orderStats = response.stats;
} else {
// Fallback to client-side calculation for backwards compatibility
this.updateOrderStats();
}
} catch (error) {
marketplaceLetzshopLog.error('Failed to load orders:', error);
this.error = error.message || 'Failed to load orders';
@@ -405,13 +414,16 @@ function adminMarketplaceLetzshop() {
},
/**
* Update order stats based on current orders
* Update order stats based on current orders (fallback method)
*
* Note: Server now returns stats with all orders counted.
* This method is kept as a fallback for backwards compatibility.
*/
updateOrderStats() {
// Reset stats
this.orderStats = { pending: 0, confirmed: 0, rejected: 0, shipped: 0 };
// Count from orders list
// Count from orders list (only visible page - not accurate for totals)
for (const order of this.orders) {
if (this.orderStats.hasOwnProperty(order.sync_status)) {
this.orderStats[order.sync_status]++;
@@ -441,6 +453,37 @@ function adminMarketplaceLetzshop() {
}
},
/**
* Import historical orders from Letzshop (all confirmed orders)
*/
async importHistoricalOrders() {
if (!this.selectedVendor || !this.letzshopStatus.is_configured) return;
this.importingHistorical = true;
this.error = '';
this.successMessage = '';
this.historicalImportResult = null;
try {
const response = await apiClient.post(
`/admin/letzshop/vendors/${this.selectedVendor.id}/import-history?state=confirmed`
);
this.historicalImportResult = response;
this.successMessage = `Historical import complete: ${response.imported} imported, ${response.updated} updated`;
marketplaceLetzshopLog.info('Historical import result:', response);
// Reload orders to show new data
await this.loadOrders();
} catch (error) {
marketplaceLetzshopLog.error('Failed to import historical orders:', error);
this.error = error.message || 'Failed to import historical orders';
} finally {
this.importingHistorical = false;
}
},
/**
* Confirm an order
*/

View File

@@ -287,9 +287,9 @@ class TestAdminLetzshopOrdersAPI:
"id": "gid://letzshop/Order/111",
"number": "LS-ADMIN-001",
"email": "sync@example.com",
"totalPrice": {"amount": "200.00", "currency": "EUR"},
"total": "200.00",
},
"inventoryUnits": {"nodes": []},
"inventoryUnits": [],
}
]
}

View File

@@ -344,13 +344,11 @@ class TestVendorLetzshopOrdersAPI:
"id": "gid://letzshop/Order/456",
"number": "LS-2025-001",
"email": "customer@example.com",
"totalPrice": {"amount": "99.99", "currency": "EUR"},
},
"inventoryUnits": {
"nodes": [
{"id": "unit_1", "state": "unconfirmed"},
]
"total": "99.99",
},
"inventoryUnits": [
{"id": "unit_1", "state": "unconfirmed"},
],
}
]
}

View File

@@ -227,3 +227,131 @@ class TestProductModel:
assert info["brand"] == "SourceBrand"
assert info["brand_overridden"] is False
assert info["brand_source"] == "SourceBrand"
@pytest.mark.unit
@pytest.mark.database
@pytest.mark.inventory
class TestProductInventoryProperties:
"""Test Product inventory properties including digital product handling."""
def test_physical_product_no_inventory_returns_zero(
self, db, test_vendor, test_marketplace_product
):
"""Test physical product with no inventory entries returns 0."""
# Ensure product is physical
test_marketplace_product.is_digital = False
db.commit()
product = Product(
vendor_id=test_vendor.id,
marketplace_product_id=test_marketplace_product.id,
)
db.add(product)
db.commit()
db.refresh(product)
assert product.is_digital is False
assert product.has_unlimited_inventory is False
assert product.total_inventory == 0
assert product.available_inventory == 0
def test_physical_product_with_inventory(
self, db, test_vendor, test_marketplace_product
):
"""Test physical product calculates inventory from entries."""
from models.database.inventory import Inventory
test_marketplace_product.is_digital = False
db.commit()
product = Product(
vendor_id=test_vendor.id,
marketplace_product_id=test_marketplace_product.id,
)
db.add(product)
db.commit()
db.refresh(product)
# Add inventory entries
inv1 = Inventory(
product_id=product.id,
vendor_id=test_vendor.id,
location="WAREHOUSE_A",
quantity=100,
reserved_quantity=10,
)
inv2 = Inventory(
product_id=product.id,
vendor_id=test_vendor.id,
location="WAREHOUSE_B",
quantity=50,
reserved_quantity=5,
)
db.add_all([inv1, inv2])
db.commit()
db.refresh(product)
assert product.has_unlimited_inventory is False
assert product.total_inventory == 150 # 100 + 50
assert product.available_inventory == 135 # (100-10) + (50-5)
def test_digital_product_has_unlimited_inventory(
self, db, test_vendor, test_marketplace_product
):
"""Test digital product returns unlimited inventory."""
test_marketplace_product.is_digital = True
db.commit()
product = Product(
vendor_id=test_vendor.id,
marketplace_product_id=test_marketplace_product.id,
)
db.add(product)
db.commit()
db.refresh(product)
assert product.is_digital is True
assert product.has_unlimited_inventory is True
assert product.total_inventory == Product.UNLIMITED_INVENTORY
assert product.available_inventory == Product.UNLIMITED_INVENTORY
def test_digital_product_ignores_inventory_entries(
self, db, test_vendor, test_marketplace_product
):
"""Test digital product returns unlimited even with inventory entries."""
from models.database.inventory import Inventory
test_marketplace_product.is_digital = True
db.commit()
product = Product(
vendor_id=test_vendor.id,
marketplace_product_id=test_marketplace_product.id,
)
db.add(product)
db.commit()
db.refresh(product)
# Add inventory entries (e.g., for license keys)
inv = Inventory(
product_id=product.id,
vendor_id=test_vendor.id,
location="DIGITAL_LICENSES",
quantity=10,
reserved_quantity=2,
)
db.add(inv)
db.commit()
db.refresh(product)
# Digital product should still return unlimited
assert product.has_unlimited_inventory is True
assert product.total_inventory == Product.UNLIMITED_INVENTORY
assert product.available_inventory == Product.UNLIMITED_INVENTORY
def test_unlimited_inventory_constant(self):
"""Test UNLIMITED_INVENTORY constant value."""
assert Product.UNLIMITED_INVENTORY == 999999
# Should be large enough to never cause "insufficient inventory"
assert Product.UNLIMITED_INVENTORY > 100000

View File

@@ -447,3 +447,303 @@ class TestLetzshopClient:
client.get_shipments()
assert "Invalid shipment ID" in str(exc_info.value)
@patch("requests.Session.post")
def test_get_all_shipments_paginated(self, mock_post):
"""Test paginated shipment fetching."""
# First page response
page1_response = MagicMock()
page1_response.status_code = 200
page1_response.json.return_value = {
"data": {
"shipments": {
"pageInfo": {
"hasNextPage": True,
"endCursor": "cursor_1",
},
"nodes": [
{"id": "ship_1", "state": "confirmed"},
{"id": "ship_2", "state": "confirmed"},
],
}
}
}
# Second page response
page2_response = MagicMock()
page2_response.status_code = 200
page2_response.json.return_value = {
"data": {
"shipments": {
"pageInfo": {
"hasNextPage": False,
"endCursor": None,
},
"nodes": [
{"id": "ship_3", "state": "confirmed"},
],
}
}
}
mock_post.side_effect = [page1_response, page2_response]
client = LetzshopClient(api_key="test-key")
shipments = client.get_all_shipments_paginated(
state="confirmed",
page_size=2,
)
assert len(shipments) == 3
assert shipments[0]["id"] == "ship_1"
assert shipments[2]["id"] == "ship_3"
@patch("requests.Session.post")
def test_get_all_shipments_paginated_with_max_pages(self, mock_post):
"""Test paginated fetching respects max_pages limit."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"shipments": {
"pageInfo": {
"hasNextPage": True,
"endCursor": "cursor_1",
},
"nodes": [
{"id": "ship_1", "state": "confirmed"},
],
}
}
}
mock_post.return_value = mock_response
client = LetzshopClient(api_key="test-key")
shipments = client.get_all_shipments_paginated(
state="confirmed",
page_size=1,
max_pages=1, # Only fetch 1 page
)
assert len(shipments) == 1
assert mock_post.call_count == 1
@patch("requests.Session.post")
def test_get_all_shipments_paginated_with_callback(self, mock_post):
"""Test paginated fetching calls progress callback."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"shipments": {
"pageInfo": {"hasNextPage": False, "endCursor": None},
"nodes": [{"id": "ship_1"}],
}
}
}
mock_post.return_value = mock_response
callback_calls = []
def callback(page, total):
callback_calls.append((page, total))
client = LetzshopClient(api_key="test-key")
client.get_all_shipments_paginated(
state="confirmed",
progress_callback=callback,
)
assert len(callback_calls) == 1
assert callback_calls[0] == (1, 1)
# ============================================================================
# Order Service Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.letzshop
class TestLetzshopOrderService:
"""Test suite for Letzshop order service."""
def test_create_order_extracts_locale(self, db, test_vendor):
"""Test that create_order extracts customer locale."""
from app.services.letzshop.order_service import LetzshopOrderService
service = LetzshopOrderService(db)
shipment_data = {
"id": "ship_123",
"state": "confirmed",
"order": {
"id": "order_123",
"number": "R123456",
"email": "test@example.com",
"total": 29.99,
"locale": "fr",
"shipAddress": {
"firstName": "Jean",
"lastName": "Dupont",
"country": {"iso": "LU"},
},
"billAddress": {
"country": {"iso": "FR"},
},
},
"inventoryUnits": [],
}
order = service.create_order(test_vendor.id, shipment_data)
assert order.customer_locale == "fr"
assert order.shipping_country_iso == "LU"
assert order.billing_country_iso == "FR"
def test_create_order_extracts_ean(self, db, test_vendor):
"""Test that create_order extracts EAN from tradeId."""
from app.services.letzshop.order_service import LetzshopOrderService
service = LetzshopOrderService(db)
shipment_data = {
"id": "ship_123",
"state": "confirmed",
"order": {
"id": "order_123",
"number": "R123456",
"email": "test@example.com",
"total": 29.99,
"shipAddress": {},
},
"inventoryUnits": [
{
"id": "unit_1",
"state": "confirmed",
"variant": {
"id": "var_1",
"sku": "SKU123",
"mpn": "MPN456",
"price": 19.99,
"tradeId": {
"number": "0889698273022",
"parser": "gtin13",
},
"product": {
"name": {"en": "Test Product", "fr": "Produit Test"},
},
},
}
],
}
order = service.create_order(test_vendor.id, shipment_data)
assert len(order.inventory_units) == 1
unit = order.inventory_units[0]
assert unit["ean"] == "0889698273022"
assert unit["ean_type"] == "gtin13"
assert unit["sku"] == "SKU123"
assert unit["mpn"] == "MPN456"
assert unit["product_name"] == "Test Product"
assert unit["price"] == 19.99
def test_import_historical_shipments_deduplication(self, db, test_vendor):
"""Test that historical import deduplicates existing orders."""
from app.services.letzshop.order_service import LetzshopOrderService
service = LetzshopOrderService(db)
shipment_data = {
"id": "ship_existing",
"state": "confirmed",
"order": {
"id": "order_123",
"number": "R123456",
"email": "test@example.com",
"total": 29.99,
"shipAddress": {},
},
"inventoryUnits": [],
}
# Create first order
service.create_order(test_vendor.id, shipment_data)
db.commit()
# Import same shipment again
stats = service.import_historical_shipments(
vendor_id=test_vendor.id,
shipments=[shipment_data],
match_products=False,
)
assert stats["total"] == 1
assert stats["imported"] == 0
assert stats["skipped"] == 1
def test_import_historical_shipments_new_orders(self, db, test_vendor):
"""Test that historical import creates new orders."""
from app.services.letzshop.order_service import LetzshopOrderService
service = LetzshopOrderService(db)
shipments = [
{
"id": f"ship_{i}",
"state": "confirmed",
"order": {
"id": f"order_{i}",
"number": f"R{i}",
"email": f"customer{i}@example.com",
"total": 29.99,
"shipAddress": {},
},
"inventoryUnits": [],
}
for i in range(3)
]
stats = service.import_historical_shipments(
vendor_id=test_vendor.id,
shipments=shipments,
match_products=False,
)
assert stats["total"] == 3
assert stats["imported"] == 3
assert stats["skipped"] == 0
def test_get_historical_import_summary(self, db, test_vendor):
"""Test historical import summary statistics."""
from app.services.letzshop.order_service import LetzshopOrderService
service = LetzshopOrderService(db)
# Create some orders with different locales
for i, locale in enumerate(["fr", "fr", "de", "en"]):
shipment_data = {
"id": f"ship_{i}",
"state": "confirmed",
"order": {
"id": f"order_{i}",
"number": f"R{i}",
"email": f"customer{i}@example.com",
"total": 29.99,
"locale": locale,
"shipAddress": {"country": {"iso": "LU"}},
},
"inventoryUnits": [],
}
service.create_order(test_vendor.id, shipment_data)
db.commit()
summary = service.get_historical_import_summary(test_vendor.id)
assert summary["total_orders"] == 4
assert summary["unique_customers"] == 4
assert summary["orders_by_locale"]["fr"] == 2
assert summary["orders_by_locale"]["de"] == 1
assert summary["orders_by_locale"]["en"] == 1