wip: update Letzshop service and API for historical imports

- Add historical order import functionality
- Add order detail page route
- Update API endpoints for order confirmation flow

Note: These files need further updates to use the new unified order model.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-19 21:18:39 +01:00
parent 473a4fabfc
commit fceaba703e
3 changed files with 338 additions and 55 deletions

View File

@@ -8,13 +8,14 @@ architecture rules (API-002: endpoints should not contain business logic).
import logging
from datetime import UTC, datetime
from typing import Any
from typing import Any, Callable
from sqlalchemy import func
from sqlalchemy import String, func
from sqlalchemy.orm import Session
from models.database.letzshop import (
LetzshopFulfillmentQueue,
LetzshopHistoricalImportJob,
LetzshopOrder,
LetzshopSyncLog,
VendorLetzshopCredentials,
@@ -166,6 +167,14 @@ class LetzshopOrderService:
.first()
)
def get_order_by_id(self, order_id: int) -> LetzshopOrder | None:
"""Get a Letzshop order by its database ID."""
return (
self.db.query(LetzshopOrder)
.filter(LetzshopOrder.id == order_id)
.first()
)
def list_orders(
self,
vendor_id: int,
@@ -173,12 +182,26 @@ class LetzshopOrderService:
limit: int = 50,
sync_status: str | None = None,
letzshop_state: str | None = None,
has_declined_items: bool | None = None,
search: str | None = None,
) -> tuple[list[LetzshopOrder], int]:
"""
List Letzshop orders for a vendor.
Args:
vendor_id: Vendor ID to filter by.
skip: Number of records to skip.
limit: Maximum number of records to return.
sync_status: Filter by order sync status (pending, confirmed, etc.)
letzshop_state: Filter by Letzshop shipment state.
has_declined_items: If True, only return orders with at least one
declined/unavailable item (confirmed_unavailable state).
search: Search by order number, customer name, or customer email.
Returns a tuple of (orders, total_count).
"""
from sqlalchemy import or_
query = self.db.query(LetzshopOrder).filter(
LetzshopOrder.vendor_id == vendor_id
)
@@ -188,6 +211,25 @@ class LetzshopOrderService:
if letzshop_state:
query = query.filter(LetzshopOrder.letzshop_state == letzshop_state)
# Search by order number, customer name, or email
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
LetzshopOrder.letzshop_order_number.ilike(search_term),
LetzshopOrder.customer_name.ilike(search_term),
LetzshopOrder.customer_email.ilike(search_term),
)
)
# Filter for orders with declined items (confirmed_unavailable state)
if has_declined_items is True:
# Use JSON contains check for SQLite/PostgreSQL
query = query.filter(
LetzshopOrder.inventory_units.isnot(None),
LetzshopOrder.inventory_units.cast(String).contains("confirmed_unavailable"),
)
total = query.count()
orders = (
query.order_by(LetzshopOrder.created_at.desc())
@@ -203,7 +245,8 @@ class LetzshopOrderService:
Get order counts by sync_status for a vendor.
Returns:
Dict with counts for each status: pending, confirmed, rejected, shipped
Dict with counts for each status: pending, confirmed, rejected, shipped,
and has_declined_items (orders with at least one declined item).
"""
status_counts = (
self.db.query(
@@ -221,6 +264,19 @@ class LetzshopOrderService:
if status in stats:
stats[status] = count
# Count orders with declined items (confirmed_unavailable state)
declined_items_count = (
self.db.query(func.count(LetzshopOrder.id))
.filter(
LetzshopOrder.vendor_id == vendor_id,
LetzshopOrder.inventory_units.isnot(None),
LetzshopOrder.inventory_units.cast(String).contains("confirmed_unavailable"),
)
.scalar()
or 0
)
stats["has_declined_items"] = declined_items_count
return stats
def create_order(
@@ -246,6 +302,20 @@ class LetzshopOrderService:
# Extract customer locale (language preference for invoicing)
customer_locale = order_data.get("locale")
# Extract order date (completedAt from Letzshop)
order_date = None
completed_at_str = order_data.get("completedAt")
if completed_at_str:
try:
from datetime import datetime
# Handle ISO format with timezone
if completed_at_str.endswith("Z"):
completed_at_str = completed_at_str[:-1] + "+00:00"
order_date = datetime.fromisoformat(completed_at_str)
except (ValueError, TypeError):
pass # Keep None if parsing fails
# Extract country codes
ship_country = ship_address.get("country", {}) or {}
shipping_country_iso = ship_country.get("iso")
@@ -316,6 +386,7 @@ class LetzshopOrderService:
billing_country_iso=billing_country_iso,
total_amount=total_amount,
currency=currency,
order_date=order_date,
raw_order_data=shipment_data,
inventory_units=enriched_units,
sync_status=sync_status,
@@ -347,6 +418,19 @@ class LetzshopOrderService:
if not order.customer_locale and order_data.get("locale"):
order.customer_locale = order_data.get("locale")
# Update order_date if not already set
if not order.order_date:
completed_at_str = order_data.get("completedAt")
if completed_at_str:
try:
from datetime import datetime
if completed_at_str.endswith("Z"):
completed_at_str = completed_at_str[:-1] + "+00:00"
order.order_date = datetime.fromisoformat(completed_at_str)
except (ValueError, TypeError):
pass
# Update country codes if not already set
if not order.shipping_country_iso:
ship_address = order_data.get("shipAddress", {}) or {}
@@ -632,6 +716,7 @@ class LetzshopOrderService:
vendor_id: int,
shipments: list[dict[str, Any]],
match_products: bool = True,
progress_callback: Callable[[int, int, int, int], None] | None = None,
) -> dict[str, Any]:
"""
Import historical shipments into the database.
@@ -640,6 +725,8 @@ class LetzshopOrderService:
vendor_id: Vendor ID to import for.
shipments: List of shipment data from Letzshop API.
match_products: Whether to match EAN to local products.
progress_callback: Optional callback(processed, imported, updated, skipped)
for progress updates during import.
Returns:
Dict with import statistics:
@@ -662,7 +749,7 @@ class LetzshopOrderService:
"eans_not_found": set(),
}
for shipment in shipments:
for i, shipment in enumerate(shipments):
shipment_id = shipment.get("id")
if not shipment_id:
continue
@@ -671,11 +758,13 @@ class LetzshopOrderService:
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)
# Check if we need to update (e.g., state changed or missing data)
shipment_state = shipment.get("state")
needs_update = False
if existing_order.letzshop_state != shipment_state:
self.update_order_from_shipment(existing_order, shipment)
stats["updated"] += 1
needs_update = True
else:
# Also fix sync_status if it's out of sync with letzshop_state
state_mapping = {
@@ -686,9 +775,27 @@ class LetzshopOrderService:
expected_sync_status = state_mapping.get(shipment_state, "confirmed")
if existing_order.sync_status != expected_sync_status:
existing_order.sync_status = expected_sync_status
stats["updated"] += 1
else:
stats["skipped"] += 1
needs_update = True
# Populate order_date if missing (for orders imported before this field existed)
if not existing_order.order_date:
order_data = shipment.get("order", {})
completed_at_str = order_data.get("completedAt")
if completed_at_str:
try:
from datetime import datetime
if completed_at_str.endswith("Z"):
completed_at_str = completed_at_str[:-1] + "+00:00"
existing_order.order_date = datetime.fromisoformat(completed_at_str)
needs_update = True
except (ValueError, TypeError):
pass
if needs_update:
stats["updated"] += 1
else:
stats["skipped"] += 1
else:
# Create new order
self.create_order(vendor_id, shipment)
@@ -705,6 +812,15 @@ class LetzshopOrderService:
if ean:
stats["eans_processed"].add(ean)
# Report progress every 10 shipments or at the end
if progress_callback and ((i + 1) % 10 == 0 or i == len(shipments) - 1):
progress_callback(
i + 1,
stats["imported"],
stats["updated"],
stats["skipped"],
)
# Match EANs to local products
if match_products and stats["eans_processed"]:
matched, not_found = self._match_eans_to_products(
@@ -853,3 +969,80 @@ class LetzshopOrderService:
"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},
}
# =========================================================================
# Historical Import Job Operations
# =========================================================================
def get_running_historical_import_job(
self,
vendor_id: int,
) -> LetzshopHistoricalImportJob | None:
"""
Get any running historical import job for a vendor.
Args:
vendor_id: Vendor ID to check.
Returns:
Running job or None if no active job.
"""
return (
self.db.query(LetzshopHistoricalImportJob)
.filter(
LetzshopHistoricalImportJob.vendor_id == vendor_id,
LetzshopHistoricalImportJob.status.in_(
["pending", "fetching", "processing"]
),
)
.first()
)
def create_historical_import_job(
self,
vendor_id: int,
user_id: int,
) -> LetzshopHistoricalImportJob:
"""
Create a new historical import job.
Args:
vendor_id: Vendor ID to import for.
user_id: User ID who initiated the import.
Returns:
Created job record.
"""
job = LetzshopHistoricalImportJob(
vendor_id=vendor_id,
user_id=user_id,
status="pending",
)
self.db.add(job)
self.db.commit()
self.db.refresh(job)
return job
def get_historical_import_job_by_id(
self,
vendor_id: int,
job_id: int,
) -> LetzshopHistoricalImportJob | None:
"""
Get a historical import job by ID.
Args:
vendor_id: Vendor ID to verify ownership.
job_id: Job ID to fetch.
Returns:
Job record or None if not found.
"""
return (
self.db.query(LetzshopHistoricalImportJob)
.filter(
LetzshopHistoricalImportJob.id == job_id,
LetzshopHistoricalImportJob.vendor_id == vendor_id,
)
.first()
)