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:
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user