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:
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# ========================================================================
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
517
docs/implementation/letzshop-order-import-improvements.md
Normal file
517
docs/implementation/letzshop-order-import-improvements.md
Normal 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 |
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
725
scripts/letzshop_introspect.py
Normal file
725
scripts/letzshop_introspect.py
Normal 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()
|
||||
723
scripts/test_historical_import.py
Normal file
723
scripts/test_historical_import.py
Normal 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()
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
tests/integration/api/v1/vendor/test_letzshop.py
vendored
10
tests/integration/api/v1/vendor/test_letzshop.py
vendored
@@ -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"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user