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