Update remaining application code to use canonical module locations: - app/services/letzshop/order_service.py - app/services/platform_health_service.py - alembic/env.py (inventory and order models) - scripts/investigate_order.py, verify_setup.py, seed_demo.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1137 lines
39 KiB
Python
1137 lines
39 KiB
Python
# app/services/letzshop/order_service.py
|
|
"""
|
|
Letzshop order service for handling order-related database operations.
|
|
|
|
This service handles Letzshop-specific order operations while using the
|
|
unified Order model. All Letzshop orders are stored in the `orders` table
|
|
with `channel='letzshop'`.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Callable
|
|
|
|
from sqlalchemy import String, and_, func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.services.order_service import order_service as unified_order_service
|
|
from app.services.subscription_service import subscription_service
|
|
from models.database.letzshop import (
|
|
LetzshopFulfillmentQueue,
|
|
LetzshopHistoricalImportJob,
|
|
LetzshopSyncLog,
|
|
VendorLetzshopCredentials,
|
|
)
|
|
from models.database.marketplace_import_job import MarketplaceImportJob
|
|
from app.modules.orders.models import Order, OrderItem
|
|
from models.database.product import Product
|
|
from models.database.vendor import Vendor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class VendorNotFoundError(Exception):
|
|
"""Raised when a vendor is not found."""
|
|
|
|
|
|
class OrderNotFoundError(Exception):
|
|
"""Raised when an order is not found."""
|
|
|
|
|
|
class LetzshopOrderService:
|
|
"""Service for Letzshop order database operations using unified Order model."""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
# =========================================================================
|
|
# Vendor Operations
|
|
# =========================================================================
|
|
|
|
def get_vendor(self, vendor_id: int) -> Vendor | None:
|
|
"""Get vendor by ID."""
|
|
return self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
|
|
|
def get_vendor_or_raise(self, vendor_id: int) -> Vendor:
|
|
"""Get vendor by ID or raise VendorNotFoundError."""
|
|
vendor = self.get_vendor(vendor_id)
|
|
if vendor is None:
|
|
raise VendorNotFoundError(f"Vendor with ID {vendor_id} not found")
|
|
return vendor
|
|
|
|
def list_vendors_with_letzshop_status(
|
|
self,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
configured_only: bool = False,
|
|
) -> tuple[list[dict[str, Any]], int]:
|
|
"""
|
|
List vendors with their Letzshop integration status.
|
|
|
|
Returns a tuple of (vendor_overviews, total_count).
|
|
"""
|
|
query = self.db.query(Vendor).filter(Vendor.is_active == True) # noqa: E712
|
|
|
|
if configured_only:
|
|
query = query.join(
|
|
VendorLetzshopCredentials,
|
|
Vendor.id == VendorLetzshopCredentials.vendor_id,
|
|
)
|
|
|
|
total = query.count()
|
|
vendors = query.order_by(Vendor.name).offset(skip).limit(limit).all()
|
|
|
|
vendor_overviews = []
|
|
for vendor in vendors:
|
|
credentials = (
|
|
self.db.query(VendorLetzshopCredentials)
|
|
.filter(VendorLetzshopCredentials.vendor_id == vendor.id)
|
|
.first()
|
|
)
|
|
|
|
# Count Letzshop orders from unified orders table
|
|
pending_orders = 0
|
|
total_orders = 0
|
|
if credentials:
|
|
pending_orders = (
|
|
self.db.query(func.count(Order.id))
|
|
.filter(
|
|
Order.vendor_id == vendor.id,
|
|
Order.channel == "letzshop",
|
|
Order.status == "pending",
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
total_orders = (
|
|
self.db.query(func.count(Order.id))
|
|
.filter(
|
|
Order.vendor_id == vendor.id,
|
|
Order.channel == "letzshop",
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
|
|
vendor_overviews.append(
|
|
{
|
|
"vendor_id": vendor.id,
|
|
"vendor_name": vendor.name,
|
|
"vendor_code": vendor.vendor_code,
|
|
"is_configured": credentials is not None,
|
|
"auto_sync_enabled": credentials.auto_sync_enabled
|
|
if credentials
|
|
else False,
|
|
"last_sync_at": credentials.last_sync_at if credentials else None,
|
|
"last_sync_status": credentials.last_sync_status
|
|
if credentials
|
|
else None,
|
|
"pending_orders": pending_orders,
|
|
"total_orders": total_orders,
|
|
}
|
|
)
|
|
|
|
return vendor_overviews, total
|
|
|
|
# =========================================================================
|
|
# Order Operations (using unified Order model)
|
|
# =========================================================================
|
|
|
|
def get_order(self, vendor_id: int, order_id: int) -> Order | None:
|
|
"""Get a Letzshop order by ID for a specific vendor."""
|
|
return (
|
|
self.db.query(Order)
|
|
.filter(
|
|
Order.id == order_id,
|
|
Order.vendor_id == vendor_id,
|
|
Order.channel == "letzshop",
|
|
)
|
|
.first()
|
|
)
|
|
|
|
def get_order_or_raise(self, vendor_id: int, order_id: int) -> Order:
|
|
"""Get a Letzshop order or raise OrderNotFoundError."""
|
|
order = self.get_order(vendor_id, order_id)
|
|
if order is None:
|
|
raise OrderNotFoundError(f"Order {order_id} not found")
|
|
return order
|
|
|
|
def get_order_by_shipment_id(
|
|
self, vendor_id: int, shipment_id: str
|
|
) -> Order | None:
|
|
"""Get a Letzshop order by external shipment ID."""
|
|
return (
|
|
self.db.query(Order)
|
|
.filter(
|
|
Order.vendor_id == vendor_id,
|
|
Order.channel == "letzshop",
|
|
Order.external_shipment_id == shipment_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
def get_order_by_id(self, order_id: int) -> Order | None:
|
|
"""Get a Letzshop order by its database ID."""
|
|
return (
|
|
self.db.query(Order)
|
|
.filter(
|
|
Order.id == order_id,
|
|
Order.channel == "letzshop",
|
|
)
|
|
.first()
|
|
)
|
|
|
|
def list_orders(
|
|
self,
|
|
vendor_id: int | None = None,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
status: str | None = None,
|
|
has_declined_items: bool | None = None,
|
|
search: str | None = None,
|
|
) -> tuple[list[Order], int]:
|
|
"""
|
|
List Letzshop orders for a vendor (or all vendors).
|
|
|
|
Args:
|
|
vendor_id: Vendor ID to filter by. If None, returns all vendors.
|
|
skip: Number of records to skip.
|
|
limit: Maximum number of records to return.
|
|
status: Filter by order status (pending, processing, shipped, etc.)
|
|
has_declined_items: If True, only return orders with declined items.
|
|
search: Search by order number, customer name, or email.
|
|
|
|
Returns a tuple of (orders, total_count).
|
|
"""
|
|
query = self.db.query(Order).filter(
|
|
Order.channel == "letzshop",
|
|
)
|
|
|
|
# Filter by vendor if specified
|
|
if vendor_id is not None:
|
|
query = query.filter(Order.vendor_id == vendor_id)
|
|
|
|
if status:
|
|
query = query.filter(Order.status == status)
|
|
|
|
if search:
|
|
search_term = f"%{search}%"
|
|
query = query.filter(
|
|
or_(
|
|
Order.order_number.ilike(search_term),
|
|
Order.external_order_number.ilike(search_term),
|
|
Order.customer_email.ilike(search_term),
|
|
Order.customer_first_name.ilike(search_term),
|
|
Order.customer_last_name.ilike(search_term),
|
|
)
|
|
)
|
|
|
|
# Filter for orders with declined items
|
|
if has_declined_items is True:
|
|
# Subquery to find orders with declined items
|
|
declined_order_ids = (
|
|
self.db.query(OrderItem.order_id)
|
|
.filter(OrderItem.item_state == "confirmed_unavailable")
|
|
.subquery()
|
|
)
|
|
query = query.filter(Order.id.in_(declined_order_ids))
|
|
|
|
total = query.count()
|
|
orders = (
|
|
query.order_by(Order.order_date.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
return orders, total
|
|
|
|
def get_order_stats(self, vendor_id: int | None = None) -> dict[str, int]:
|
|
"""
|
|
Get order counts by status for Letzshop orders.
|
|
|
|
Args:
|
|
vendor_id: Vendor ID to filter by. If None, returns stats for all vendors.
|
|
|
|
Returns:
|
|
Dict with counts for each status.
|
|
"""
|
|
query = self.db.query(
|
|
Order.status,
|
|
func.count(Order.id).label("count"),
|
|
).filter(Order.channel == "letzshop")
|
|
|
|
if vendor_id is not None:
|
|
query = query.filter(Order.vendor_id == vendor_id)
|
|
|
|
status_counts = query.group_by(Order.status).all()
|
|
|
|
stats = {
|
|
"pending": 0,
|
|
"processing": 0,
|
|
"shipped": 0,
|
|
"delivered": 0,
|
|
"cancelled": 0,
|
|
"refunded": 0,
|
|
"total": 0,
|
|
}
|
|
for status, count in status_counts:
|
|
if status in stats:
|
|
stats[status] = count
|
|
stats["total"] += count
|
|
|
|
# Count orders with declined items
|
|
declined_query = (
|
|
self.db.query(func.count(func.distinct(OrderItem.order_id)))
|
|
.join(Order, OrderItem.order_id == Order.id)
|
|
.filter(
|
|
Order.channel == "letzshop",
|
|
OrderItem.item_state == "confirmed_unavailable",
|
|
)
|
|
)
|
|
if vendor_id is not None:
|
|
declined_query = declined_query.filter(Order.vendor_id == vendor_id)
|
|
|
|
stats["has_declined_items"] = declined_query.scalar() or 0
|
|
|
|
return stats
|
|
|
|
def create_order(
|
|
self,
|
|
vendor_id: int,
|
|
shipment_data: dict[str, Any],
|
|
) -> Order:
|
|
"""
|
|
Create a new Letzshop order from shipment data.
|
|
|
|
Uses the unified order service to create the order.
|
|
"""
|
|
return unified_order_service.create_letzshop_order(
|
|
db=self.db,
|
|
vendor_id=vendor_id,
|
|
shipment_data=shipment_data,
|
|
)
|
|
|
|
def update_order_from_shipment(
|
|
self,
|
|
order: Order,
|
|
shipment_data: dict[str, Any],
|
|
) -> Order:
|
|
"""Update an existing order from shipment data."""
|
|
order_data = shipment_data.get("order", {})
|
|
|
|
# Map Letzshop state to status
|
|
letzshop_state = shipment_data.get("state", "unconfirmed")
|
|
state_mapping = {
|
|
"unconfirmed": "pending",
|
|
"confirmed": "processing",
|
|
"declined": "cancelled",
|
|
}
|
|
new_status = state_mapping.get(letzshop_state, "processing")
|
|
|
|
# Update status if changed
|
|
if order.status != new_status:
|
|
order.status = new_status
|
|
now = datetime.now(UTC)
|
|
if new_status == "processing":
|
|
order.confirmed_at = now
|
|
elif new_status == "cancelled":
|
|
order.cancelled_at = now
|
|
|
|
# Update external data
|
|
order.external_data = shipment_data
|
|
|
|
# Update locale if not set
|
|
if not order.customer_locale and order_data.get("locale"):
|
|
order.customer_locale = order_data.get("locale")
|
|
|
|
# Update order_date if not set
|
|
if not order.order_date:
|
|
completed_at_str = order_data.get("completedAt")
|
|
if completed_at_str:
|
|
try:
|
|
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 inventory unit states in order items
|
|
inventory_units_data = shipment_data.get("inventoryUnits", [])
|
|
if isinstance(inventory_units_data, dict):
|
|
inventory_units_data = inventory_units_data.get("nodes", [])
|
|
|
|
for unit in inventory_units_data:
|
|
unit_id = unit.get("id")
|
|
unit_state = unit.get("state")
|
|
if unit_id and unit_state:
|
|
# Find and update the corresponding order item
|
|
item = (
|
|
self.db.query(OrderItem)
|
|
.filter(
|
|
OrderItem.order_id == order.id,
|
|
OrderItem.external_item_id == unit_id,
|
|
)
|
|
.first()
|
|
)
|
|
if item:
|
|
item.item_state = unit_state
|
|
|
|
order.updated_at = datetime.now(UTC)
|
|
return order
|
|
|
|
def mark_order_confirmed(self, order: Order) -> Order:
|
|
"""Mark an order as confirmed (processing)."""
|
|
order.confirmed_at = datetime.now(UTC)
|
|
order.status = "processing"
|
|
order.updated_at = datetime.now(UTC)
|
|
return order
|
|
|
|
def mark_order_rejected(self, order: Order) -> Order:
|
|
"""Mark an order as rejected (cancelled)."""
|
|
order.cancelled_at = datetime.now(UTC)
|
|
order.status = "cancelled"
|
|
order.updated_at = datetime.now(UTC)
|
|
return order
|
|
|
|
def update_inventory_unit_state(
|
|
self, order: Order, item_id: str, state: str
|
|
) -> Order:
|
|
"""
|
|
Update the state of a single order item.
|
|
|
|
Args:
|
|
order: The order containing the item.
|
|
item_id: The external item ID (Letzshop inventory unit ID).
|
|
state: The new state (confirmed_available, confirmed_unavailable).
|
|
|
|
Returns:
|
|
The updated order.
|
|
"""
|
|
# Find and update the item
|
|
item = (
|
|
self.db.query(OrderItem)
|
|
.filter(
|
|
OrderItem.order_id == order.id,
|
|
OrderItem.external_item_id == item_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if item:
|
|
item.item_state = state
|
|
item.updated_at = datetime.now(UTC)
|
|
|
|
# Check if all items are now processed
|
|
all_items = (
|
|
self.db.query(OrderItem)
|
|
.filter(OrderItem.order_id == order.id)
|
|
.all()
|
|
)
|
|
|
|
all_confirmed = all(
|
|
i.item_state in ("confirmed_available", "confirmed_unavailable", "returned")
|
|
for i in all_items
|
|
)
|
|
|
|
if all_confirmed:
|
|
has_available = any(
|
|
i.item_state == "confirmed_available" for i in all_items
|
|
)
|
|
all_unavailable = all(
|
|
i.item_state == "confirmed_unavailable" for i in all_items
|
|
)
|
|
|
|
now = datetime.now(UTC)
|
|
if all_unavailable:
|
|
order.status = "cancelled"
|
|
order.cancelled_at = now
|
|
elif has_available:
|
|
order.status = "processing"
|
|
order.confirmed_at = now
|
|
|
|
order.updated_at = now
|
|
|
|
return order
|
|
|
|
def set_order_tracking(
|
|
self,
|
|
order: Order,
|
|
tracking_number: str,
|
|
tracking_provider: str,
|
|
) -> Order:
|
|
"""Set tracking information for an order."""
|
|
order.tracking_number = tracking_number
|
|
order.tracking_provider = tracking_provider
|
|
order.shipped_at = datetime.now(UTC)
|
|
order.status = "shipped"
|
|
order.updated_at = datetime.now(UTC)
|
|
return order
|
|
|
|
def get_orders_without_tracking(
|
|
self,
|
|
vendor_id: int,
|
|
limit: int = 100,
|
|
) -> list[Order]:
|
|
"""Get orders that have been confirmed but don't have tracking info."""
|
|
return (
|
|
self.db.query(Order)
|
|
.filter(
|
|
Order.vendor_id == vendor_id,
|
|
Order.channel == "letzshop",
|
|
Order.status == "processing", # Confirmed orders
|
|
Order.tracking_number.is_(None),
|
|
Order.external_shipment_id.isnot(None), # Has shipment ID
|
|
)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
def update_tracking_from_shipment_data(
|
|
self,
|
|
order: Order,
|
|
shipment_data: dict[str, Any],
|
|
) -> bool:
|
|
"""
|
|
Update order tracking from Letzshop shipment data.
|
|
|
|
Args:
|
|
order: The order to update.
|
|
shipment_data: Raw shipment data from Letzshop API.
|
|
|
|
Returns:
|
|
True if tracking was updated, False otherwise.
|
|
"""
|
|
tracking_data = shipment_data.get("tracking") or {}
|
|
tracking_number = tracking_data.get("code") or tracking_data.get("number")
|
|
|
|
if not tracking_number:
|
|
return False
|
|
|
|
tracking_provider = tracking_data.get("provider")
|
|
# Handle carrier object format: tracking { carrier { name code } }
|
|
if not tracking_provider and tracking_data.get("carrier"):
|
|
carrier = tracking_data.get("carrier", {})
|
|
tracking_provider = carrier.get("code") or carrier.get("name")
|
|
|
|
order.tracking_number = tracking_number
|
|
order.tracking_provider = tracking_provider
|
|
order.updated_at = datetime.now(UTC)
|
|
|
|
logger.info(
|
|
f"Updated tracking for order {order.order_number}: "
|
|
f"{tracking_provider} {tracking_number}"
|
|
)
|
|
return True
|
|
|
|
def get_order_items(self, order: Order) -> list[OrderItem]:
|
|
"""Get all items for an order."""
|
|
return (
|
|
self.db.query(OrderItem)
|
|
.filter(OrderItem.order_id == order.id)
|
|
.all()
|
|
)
|
|
|
|
# =========================================================================
|
|
# Sync Log Operations
|
|
# =========================================================================
|
|
|
|
def list_sync_logs(
|
|
self,
|
|
vendor_id: int,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
) -> tuple[list[LetzshopSyncLog], int]:
|
|
"""List sync logs for a vendor."""
|
|
query = self.db.query(LetzshopSyncLog).filter(
|
|
LetzshopSyncLog.vendor_id == vendor_id
|
|
)
|
|
total = query.count()
|
|
logs = (
|
|
query.order_by(LetzshopSyncLog.started_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return logs, total
|
|
|
|
# =========================================================================
|
|
# Fulfillment Queue Operations
|
|
# =========================================================================
|
|
|
|
def list_fulfillment_queue(
|
|
self,
|
|
vendor_id: int,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
status: str | None = None,
|
|
) -> tuple[list[LetzshopFulfillmentQueue], int]:
|
|
"""List fulfillment queue items for a vendor."""
|
|
query = self.db.query(LetzshopFulfillmentQueue).filter(
|
|
LetzshopFulfillmentQueue.vendor_id == vendor_id
|
|
)
|
|
|
|
if status:
|
|
query = query.filter(LetzshopFulfillmentQueue.status == status)
|
|
|
|
total = query.count()
|
|
items = (
|
|
query.order_by(LetzshopFulfillmentQueue.created_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return items, total
|
|
|
|
def add_to_fulfillment_queue(
|
|
self,
|
|
vendor_id: int,
|
|
order_id: int,
|
|
operation: str,
|
|
payload: dict[str, Any],
|
|
) -> LetzshopFulfillmentQueue:
|
|
"""Add an operation to the fulfillment queue."""
|
|
queue_item = LetzshopFulfillmentQueue(
|
|
vendor_id=vendor_id,
|
|
order_id=order_id,
|
|
operation=operation,
|
|
payload=payload,
|
|
status="pending",
|
|
)
|
|
self.db.add(queue_item)
|
|
return queue_item
|
|
|
|
# =========================================================================
|
|
# Unified Jobs Operations
|
|
# =========================================================================
|
|
|
|
def list_letzshop_jobs(
|
|
self,
|
|
vendor_id: int | None = None,
|
|
job_type: str | None = None,
|
|
status: str | None = None,
|
|
skip: int = 0,
|
|
limit: int = 20,
|
|
) -> tuple[list[dict[str, Any]], int]:
|
|
"""
|
|
List unified Letzshop-related jobs for a vendor or all vendors.
|
|
|
|
Combines product imports, historical order imports, and order syncs.
|
|
If vendor_id is None, returns jobs across all vendors.
|
|
"""
|
|
jobs = []
|
|
|
|
# Fetch vendor info - for single vendor or build lookup for all vendors
|
|
if vendor_id:
|
|
vendor = self.get_vendor(vendor_id)
|
|
vendor_lookup = {vendor_id: (vendor.name if vendor else None, vendor.vendor_code if vendor else None)}
|
|
else:
|
|
# Build lookup for all vendors when showing all jobs
|
|
from models.database.vendor import Vendor
|
|
vendors = self.db.query(Vendor.id, Vendor.name, Vendor.vendor_code).all()
|
|
vendor_lookup = {v.id: (v.name, v.vendor_code) for v in vendors}
|
|
|
|
# Historical order imports from letzshop_historical_import_jobs
|
|
if job_type in (None, "historical_import"):
|
|
hist_query = self.db.query(LetzshopHistoricalImportJob)
|
|
if vendor_id:
|
|
hist_query = hist_query.filter(
|
|
LetzshopHistoricalImportJob.vendor_id == vendor_id,
|
|
)
|
|
if status:
|
|
hist_query = hist_query.filter(
|
|
LetzshopHistoricalImportJob.status == status
|
|
)
|
|
|
|
hist_jobs = hist_query.order_by(
|
|
LetzshopHistoricalImportJob.created_at.desc()
|
|
).all()
|
|
|
|
for job in hist_jobs:
|
|
v_name, v_code = vendor_lookup.get(job.vendor_id, (None, None))
|
|
jobs.append(
|
|
{
|
|
"id": job.id,
|
|
"type": "historical_import",
|
|
"status": job.status,
|
|
"created_at": job.created_at,
|
|
"started_at": job.started_at,
|
|
"completed_at": job.completed_at,
|
|
"records_processed": job.orders_processed or 0,
|
|
"records_succeeded": (job.orders_imported or 0)
|
|
+ (job.orders_updated or 0),
|
|
"records_failed": job.orders_skipped or 0,
|
|
"vendor_id": job.vendor_id,
|
|
"vendor_name": v_name,
|
|
"vendor_code": v_code,
|
|
"current_phase": job.current_phase,
|
|
"error_message": job.error_message,
|
|
}
|
|
)
|
|
|
|
# Product imports from marketplace_import_jobs
|
|
if job_type in (None, "import"):
|
|
import_query = self.db.query(MarketplaceImportJob).filter(
|
|
MarketplaceImportJob.marketplace == "Letzshop",
|
|
)
|
|
if vendor_id:
|
|
import_query = import_query.filter(
|
|
MarketplaceImportJob.vendor_id == vendor_id,
|
|
)
|
|
if status:
|
|
import_query = import_query.filter(
|
|
MarketplaceImportJob.status == status
|
|
)
|
|
|
|
import_jobs = import_query.order_by(
|
|
MarketplaceImportJob.created_at.desc()
|
|
).all()
|
|
|
|
for job in import_jobs:
|
|
v_name, v_code = vendor_lookup.get(job.vendor_id, (None, None))
|
|
jobs.append(
|
|
{
|
|
"id": job.id,
|
|
"type": "import",
|
|
"status": job.status,
|
|
"created_at": job.created_at,
|
|
"started_at": job.started_at,
|
|
"completed_at": job.completed_at,
|
|
"records_processed": job.total_processed or 0,
|
|
"records_succeeded": (job.imported_count or 0)
|
|
+ (job.updated_count or 0),
|
|
"records_failed": job.error_count or 0,
|
|
"vendor_id": job.vendor_id,
|
|
"vendor_name": v_name,
|
|
"vendor_code": v_code,
|
|
}
|
|
)
|
|
|
|
# Order syncs from letzshop_sync_logs
|
|
if job_type in (None, "order_sync"):
|
|
sync_query = self.db.query(LetzshopSyncLog).filter(
|
|
LetzshopSyncLog.operation_type == "order_import",
|
|
)
|
|
if vendor_id:
|
|
sync_query = sync_query.filter(LetzshopSyncLog.vendor_id == vendor_id)
|
|
if status:
|
|
sync_query = sync_query.filter(LetzshopSyncLog.status == status)
|
|
|
|
sync_logs = sync_query.order_by(LetzshopSyncLog.created_at.desc()).all()
|
|
|
|
for log in sync_logs:
|
|
v_name, v_code = vendor_lookup.get(log.vendor_id, (None, None))
|
|
jobs.append(
|
|
{
|
|
"id": log.id,
|
|
"type": "order_sync",
|
|
"status": log.status,
|
|
"created_at": log.created_at,
|
|
"started_at": log.started_at,
|
|
"completed_at": log.completed_at,
|
|
"records_processed": log.records_processed or 0,
|
|
"records_succeeded": log.records_succeeded or 0,
|
|
"records_failed": log.records_failed or 0,
|
|
"vendor_id": log.vendor_id,
|
|
"vendor_name": v_name,
|
|
"vendor_code": v_code,
|
|
"error_details": log.error_details,
|
|
}
|
|
)
|
|
|
|
# Product exports from letzshop_sync_logs
|
|
if job_type in (None, "export"):
|
|
export_query = self.db.query(LetzshopSyncLog).filter(
|
|
LetzshopSyncLog.operation_type == "product_export",
|
|
)
|
|
if vendor_id:
|
|
export_query = export_query.filter(LetzshopSyncLog.vendor_id == vendor_id)
|
|
if status:
|
|
export_query = export_query.filter(LetzshopSyncLog.status == status)
|
|
|
|
export_logs = export_query.order_by(
|
|
LetzshopSyncLog.created_at.desc()
|
|
).all()
|
|
|
|
for log in export_logs:
|
|
v_name, v_code = vendor_lookup.get(log.vendor_id, (None, None))
|
|
jobs.append(
|
|
{
|
|
"id": log.id,
|
|
"type": "export",
|
|
"status": log.status,
|
|
"created_at": log.created_at,
|
|
"started_at": log.started_at,
|
|
"completed_at": log.completed_at,
|
|
"records_processed": log.records_processed or 0,
|
|
"records_succeeded": log.records_succeeded or 0,
|
|
"records_failed": log.records_failed or 0,
|
|
"vendor_id": log.vendor_id,
|
|
"vendor_name": v_name,
|
|
"vendor_code": v_code,
|
|
"error_details": log.error_details,
|
|
}
|
|
)
|
|
|
|
# Sort all jobs by created_at descending
|
|
jobs.sort(key=lambda x: x["created_at"], reverse=True)
|
|
|
|
total = len(jobs)
|
|
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,
|
|
progress_callback: Callable[[int, int, int, int], None] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Import historical shipments into the unified orders table.
|
|
|
|
Args:
|
|
vendor_id: Vendor ID to import for.
|
|
shipments: List of shipment data from Letzshop API.
|
|
match_products: Whether to match GTIN to local products.
|
|
progress_callback: Optional callback(processed, imported, updated, skipped)
|
|
|
|
Returns:
|
|
Dict with import statistics.
|
|
"""
|
|
stats = {
|
|
"total": len(shipments),
|
|
"imported": 0,
|
|
"updated": 0,
|
|
"skipped": 0,
|
|
"errors": 0,
|
|
"limit_exceeded": 0,
|
|
"products_matched": 0,
|
|
"products_not_found": 0,
|
|
"eans_processed": set(),
|
|
"eans_matched": set(),
|
|
"eans_not_found": set(),
|
|
"error_messages": [],
|
|
}
|
|
|
|
# Get subscription usage upfront for batch efficiency
|
|
usage = subscription_service.get_usage(self.db, vendor_id)
|
|
orders_remaining = usage.orders_remaining # None = unlimited
|
|
|
|
for i, shipment in enumerate(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
|
|
letzshop_state = shipment.get("state")
|
|
state_mapping = {
|
|
"unconfirmed": "pending",
|
|
"confirmed": "processing",
|
|
"declined": "cancelled",
|
|
}
|
|
expected_status = state_mapping.get(letzshop_state, "processing")
|
|
|
|
needs_update = False
|
|
if existing_order.status != expected_status:
|
|
self.update_order_from_shipment(existing_order, shipment)
|
|
needs_update = True
|
|
|
|
# Update order_date if missing
|
|
if not existing_order.order_date:
|
|
order_data = shipment.get("order", {})
|
|
completed_at_str = order_data.get("completedAt")
|
|
if completed_at_str:
|
|
try:
|
|
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:
|
|
self.db.commit() # noqa: SVC-006 - background task needs incremental commits
|
|
stats["updated"] += 1
|
|
else:
|
|
stats["skipped"] += 1
|
|
else:
|
|
# Check tier limit before creating order
|
|
if orders_remaining is not None and orders_remaining <= 0:
|
|
stats["limit_exceeded"] += 1
|
|
stats["error_messages"].append(
|
|
f"Shipment {shipment_id}: Order limit reached"
|
|
)
|
|
continue
|
|
|
|
# Create new order using unified service
|
|
try:
|
|
self.create_order(vendor_id, shipment)
|
|
self.db.commit() # noqa: SVC-006 - background task needs incremental commits
|
|
stats["imported"] += 1
|
|
|
|
# Decrement remaining count for batch efficiency
|
|
if orders_remaining is not None:
|
|
orders_remaining -= 1
|
|
|
|
except Exception as e:
|
|
self.db.rollback() # Rollback failed order
|
|
stats["errors"] += 1
|
|
stats["error_messages"].append(
|
|
f"Shipment {shipment_id}: {str(e)}"
|
|
)
|
|
logger.error(f"Error importing shipment {shipment_id}: {e}")
|
|
|
|
# Process GTINs 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 {}
|
|
gtin = trade_id.get("number")
|
|
|
|
if gtin:
|
|
stats["eans_processed"].add(gtin)
|
|
|
|
# Report progress
|
|
if progress_callback and ((i + 1) % 10 == 0 or i == len(shipments) - 1):
|
|
progress_callback(
|
|
i + 1,
|
|
stats["imported"],
|
|
stats["updated"],
|
|
stats["skipped"],
|
|
)
|
|
|
|
# Match GTINs to local products
|
|
if match_products and stats["eans_processed"]:
|
|
matched, not_found = self._match_gtins_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_gtins_to_products(
|
|
self,
|
|
vendor_id: int,
|
|
gtins: list[str],
|
|
) -> tuple[set[str], set[str]]:
|
|
"""Match GTIN codes to local products."""
|
|
if not gtins:
|
|
return set(), set()
|
|
|
|
products = (
|
|
self.db.query(Product)
|
|
.filter(
|
|
Product.vendor_id == vendor_id,
|
|
Product.gtin.in_(gtins),
|
|
)
|
|
.all()
|
|
)
|
|
|
|
matched_gtins = {p.gtin for p in products if p.gtin}
|
|
not_found_gtins = set(gtins) - matched_gtins
|
|
|
|
logger.info(
|
|
f"GTIN matching: {len(matched_gtins)} matched, "
|
|
f"{len(not_found_gtins)} not found"
|
|
)
|
|
return matched_gtins, not_found_gtins
|
|
|
|
def get_products_by_gtins(
|
|
self,
|
|
vendor_id: int,
|
|
gtins: list[str],
|
|
) -> dict[str, Product]:
|
|
"""Get products by their GTIN codes."""
|
|
if not gtins:
|
|
return {}
|
|
|
|
products = (
|
|
self.db.query(Product)
|
|
.filter(
|
|
Product.vendor_id == vendor_id,
|
|
Product.gtin.in_(gtins),
|
|
)
|
|
.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 Letzshop orders for a vendor."""
|
|
# Count orders by status
|
|
status_counts = (
|
|
self.db.query(
|
|
Order.status,
|
|
func.count(Order.id).label("count"),
|
|
)
|
|
.filter(
|
|
Order.vendor_id == vendor_id,
|
|
Order.channel == "letzshop",
|
|
)
|
|
.group_by(Order.status)
|
|
.all()
|
|
)
|
|
|
|
# Count orders by locale
|
|
locale_counts = (
|
|
self.db.query(
|
|
Order.customer_locale,
|
|
func.count(Order.id).label("count"),
|
|
)
|
|
.filter(
|
|
Order.vendor_id == vendor_id,
|
|
Order.channel == "letzshop",
|
|
)
|
|
.group_by(Order.customer_locale)
|
|
.all()
|
|
)
|
|
|
|
# Count orders by country
|
|
country_counts = (
|
|
self.db.query(
|
|
Order.ship_country_iso,
|
|
func.count(Order.id).label("count"),
|
|
)
|
|
.filter(
|
|
Order.vendor_id == vendor_id,
|
|
Order.channel == "letzshop",
|
|
)
|
|
.group_by(Order.ship_country_iso)
|
|
.all()
|
|
)
|
|
|
|
# Total orders
|
|
total_orders = (
|
|
self.db.query(func.count(Order.id))
|
|
.filter(
|
|
Order.vendor_id == vendor_id,
|
|
Order.channel == "letzshop",
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
|
|
# Unique customers
|
|
unique_customers = (
|
|
self.db.query(func.count(func.distinct(Order.customer_email)))
|
|
.filter(
|
|
Order.vendor_id == vendor_id,
|
|
Order.channel == "letzshop",
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
|
|
return {
|
|
"total_orders": total_orders,
|
|
"unique_customers": unique_customers,
|
|
"orders_by_status": {status: count for status, count in status_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
|
|
},
|
|
}
|
|
|
|
# =========================================================================
|
|
# 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."""
|
|
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."""
|
|
job = LetzshopHistoricalImportJob(
|
|
vendor_id=vendor_id,
|
|
user_id=user_id,
|
|
status="pending",
|
|
)
|
|
self.db.add(job)
|
|
self.db.commit() # noqa: SVC-006 - job must be visible immediately before background task starts
|
|
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."""
|
|
return (
|
|
self.db.query(LetzshopHistoricalImportJob)
|
|
.filter(
|
|
LetzshopHistoricalImportJob.id == job_id,
|
|
LetzshopHistoricalImportJob.vendor_id == vendor_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
def update_job_celery_task_id(
|
|
self,
|
|
job_id: int,
|
|
celery_task_id: str,
|
|
) -> bool:
|
|
"""
|
|
Update the Celery task ID for a historical import job.
|
|
|
|
Args:
|
|
job_id: The job ID to update.
|
|
celery_task_id: The Celery task ID to set.
|
|
|
|
Returns:
|
|
True if updated successfully, False if job not found.
|
|
"""
|
|
job = (
|
|
self.db.query(LetzshopHistoricalImportJob)
|
|
.filter(LetzshopHistoricalImportJob.id == job_id)
|
|
.first()
|
|
)
|
|
if job:
|
|
job.celery_task_id = celery_task_id
|
|
self.db.commit() # noqa: SVC-006 - Called from API endpoint
|
|
return True
|
|
return False
|