diff --git a/alembic/versions/a9a86cef6cca_add_letzshop_order_locale_and_country_.py b/alembic/versions/a9a86cef6cca_add_letzshop_order_locale_and_country_.py
new file mode 100644
index 00000000..731d6ae6
--- /dev/null
+++ b/alembic/versions/a9a86cef6cca_add_letzshop_order_locale_and_country_.py
@@ -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')
diff --git a/alembic/versions/cb88bc9b5f86_add_gtin_columns_to_product_table.py b/alembic/versions/cb88bc9b5f86_add_gtin_columns_to_product_table.py
new file mode 100644
index 00000000..faa6a494
--- /dev/null
+++ b/alembic/versions/cb88bc9b5f86_add_gtin_columns_to_product_table.py
@@ -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')
diff --git a/app/api/v1/admin/letzshop.py b/app/api/v1/admin/letzshop.py
index 865e9b80..b12c91d5 100644
--- a/app/api/v1/admin/letzshop.py
+++ b/app/api/v1/admin/letzshop.py
@@ -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,
+ }
diff --git a/app/services/letzshop/client_service.py b/app/services/letzshop/client_service.py
index d713bc86..315fe807 100644
--- a/app/services/letzshop/client_service.py
+++ b/app/services/letzshop/client_service.py
@@ -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
# ========================================================================
diff --git a/app/services/letzshop/order_service.py b/app/services/letzshop/order_service.py
index fc9c9f28..e6a07dfe 100644
--- a/app/services/letzshop/order_service.py
+++ b/app/services/letzshop/order_service.py
@@ -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},
+ }
diff --git a/app/templates/admin/partials/letzshop-orders-tab.html b/app/templates/admin/partials/letzshop-orders-tab.html
index bdac3556..ab7c3811 100644
--- a/app/templates/admin/partials/letzshop-orders-tab.html
+++ b/app/templates/admin/partials/letzshop-orders-tab.html
@@ -1,21 +1,57 @@
{# app/templates/admin/partials/letzshop-orders-tab.html #}
{# Orders tab for admin Letzshop management #}
-
+
Orders
Manage Letzshop orders for this vendor
-
+
+
+
+
+
+
+
+
+
+
+
+
+
Historical Import Complete
+
+ ·
+ ·
+
+
+
+ ·
+
+
+
+
+
+
diff --git a/docs/implementation/letzshop-order-import-improvements.md b/docs/implementation/letzshop-order-import-improvements.md
new file mode 100644
index 00000000..1b06d27e
--- /dev/null
+++ b/docs/implementation/letzshop-order-import-improvements.md
@@ -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 |
diff --git a/models/database/letzshop.py b/models/database/letzshop.py
index af9f4019..8222c5f4 100644
--- a/models/database/letzshop.py
+++ b/models/database/letzshop.py
@@ -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)
diff --git a/models/database/product.py b/models/database/product.py
index e242ea0d..f943d4d8 100644
--- a/models/database/product.py
+++ b/models/database/product.py
@@ -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 ===
diff --git a/models/schema/letzshop.py b/models/schema/letzshop.py
index 214c1a5d..5d50e96c 100644
--- a/models/schema/letzshop.py
+++ b/models/schema/letzshop.py
@@ -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
# ============================================================================
diff --git a/scripts/letzshop_introspect.py b/scripts/letzshop_introspect.py
new file mode 100644
index 00000000..370c5578
--- /dev/null
+++ b/scripts/letzshop_introspect.py
@@ -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()
diff --git a/scripts/test_historical_import.py b/scripts/test_historical_import.py
new file mode 100644
index 00000000..ef1d0343
--- /dev/null
+++ b/scripts/test_historical_import.py
@@ -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()
diff --git a/static/admin/js/marketplace-letzshop.js b/static/admin/js/marketplace-letzshop.js
index 77fa536a..2dfa8216 100644
--- a/static/admin/js/marketplace-letzshop.js
+++ b/static/admin/js/marketplace-letzshop.js
@@ -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
*/
diff --git a/tests/integration/api/v1/admin/test_letzshop.py b/tests/integration/api/v1/admin/test_letzshop.py
index 175f0f74..fc4502f2 100644
--- a/tests/integration/api/v1/admin/test_letzshop.py
+++ b/tests/integration/api/v1/admin/test_letzshop.py
@@ -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": [],
}
]
}
diff --git a/tests/integration/api/v1/vendor/test_letzshop.py b/tests/integration/api/v1/vendor/test_letzshop.py
index 03190931..9389a4b8 100644
--- a/tests/integration/api/v1/vendor/test_letzshop.py
+++ b/tests/integration/api/v1/vendor/test_letzshop.py
@@ -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"},
+ ],
}
]
}
diff --git a/tests/unit/models/database/test_product.py b/tests/unit/models/database/test_product.py
index b2de2cbb..82ebf6b2 100644
--- a/tests/unit/models/database/test_product.py
+++ b/tests/unit/models/database/test_product.py
@@ -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
diff --git a/tests/unit/services/test_letzshop_service.py b/tests/unit/services/test_letzshop_service.py
index 6bb18dbe..74952f2a 100644
--- a/tests/unit/services/test_letzshop_service.py
+++ b/tests/unit/services/test_letzshop_service.py
@@ -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