# app/services/letzshop/order_service.py """ Letzshop order service for handling order-related database operations. This service moves database queries out of the API layer to comply with architecture rules (API-002: endpoints should not contain business logic). """ import logging from datetime import UTC, datetime from typing import Any from sqlalchemy import func from sqlalchemy.orm import Session from models.database.letzshop import ( LetzshopFulfillmentQueue, LetzshopOrder, LetzshopSyncLog, VendorLetzshopCredentials, ) from models.database.marketplace_import_job import MarketplaceImportJob 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 a Letzshop order is not found.""" class LetzshopOrderService: """Service for Letzshop order database operations.""" 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). """ # Build query query = self.db.query(Vendor).filter(Vendor.is_active == True) # noqa: E712 if configured_only: query = query.join( VendorLetzshopCredentials, Vendor.id == VendorLetzshopCredentials.vendor_id, ) # Get total count total = query.count() # Get vendors vendors = query.order_by(Vendor.name).offset(skip).limit(limit).all() # Build response with Letzshop status vendor_overviews = [] for vendor in vendors: # Get credentials credentials = ( self.db.query(VendorLetzshopCredentials) .filter(VendorLetzshopCredentials.vendor_id == vendor.id) .first() ) # Get order counts pending_orders = 0 total_orders = 0 if credentials: pending_orders = ( self.db.query(func.count(LetzshopOrder.id)) .filter( LetzshopOrder.vendor_id == vendor.id, LetzshopOrder.sync_status == "pending", ) .scalar() or 0 ) total_orders = ( self.db.query(func.count(LetzshopOrder.id)) .filter(LetzshopOrder.vendor_id == vendor.id) .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 # ========================================================================= def get_order(self, vendor_id: int, order_id: int) -> LetzshopOrder | None: """Get a Letzshop order by ID for a specific vendor.""" return ( self.db.query(LetzshopOrder) .filter( LetzshopOrder.id == order_id, LetzshopOrder.vendor_id == vendor_id, ) .first() ) def get_order_or_raise(self, vendor_id: int, order_id: int) -> LetzshopOrder: """Get a Letzshop order or raise OrderNotFoundError.""" order = self.get_order(vendor_id, order_id) if order is None: raise OrderNotFoundError(f"Letzshop order {order_id} not found") return order def get_order_by_shipment_id( self, vendor_id: int, shipment_id: str ) -> LetzshopOrder | None: """Get a Letzshop order by shipment ID.""" return ( self.db.query(LetzshopOrder) .filter( LetzshopOrder.vendor_id == vendor_id, LetzshopOrder.letzshop_shipment_id == shipment_id, ) .first() ) def list_orders( self, vendor_id: int, skip: int = 0, limit: int = 50, sync_status: str | None = None, letzshop_state: str | None = None, ) -> tuple[list[LetzshopOrder], int]: """ List Letzshop orders for a vendor. Returns a tuple of (orders, total_count). """ query = self.db.query(LetzshopOrder).filter( LetzshopOrder.vendor_id == vendor_id ) if sync_status: query = query.filter(LetzshopOrder.sync_status == sync_status) if letzshop_state: query = query.filter(LetzshopOrder.letzshop_state == letzshop_state) total = query.count() orders = ( query.order_by(LetzshopOrder.created_at.desc()) .offset(skip) .limit(limit) .all() ) return orders, total def create_order( self, vendor_id: int, shipment_data: dict[str, Any], ) -> LetzshopOrder: """Create a new Letzshop order from shipment data.""" order_data = shipment_data.get("order", {}) 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"), customer_email=order_data.get("email"), total_amount=str(order_data.get("totalPrice", {}).get("amount", "")), currency=order_data.get("totalPrice", {}).get("currency", "EUR"), 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", ) self.db.add(order) return order def update_order_from_shipment( self, order: LetzshopOrder, shipment_data: dict[str, Any], ) -> LetzshopOrder: """Update an existing order from shipment data.""" order.letzshop_state = shipment_data.get("state") order.raw_order_data = shipment_data return order def mark_order_confirmed(self, order: LetzshopOrder) -> LetzshopOrder: """Mark an order as confirmed.""" order.confirmed_at = datetime.now(UTC) order.sync_status = "confirmed" return order def mark_order_rejected(self, order: LetzshopOrder) -> LetzshopOrder: """Mark an order as rejected.""" order.rejected_at = datetime.now(UTC) order.sync_status = "rejected" return order def set_order_tracking( self, order: LetzshopOrder, tracking_number: str, tracking_carrier: str, ) -> LetzshopOrder: """Set tracking information for an order.""" order.tracking_number = tracking_number order.tracking_carrier = tracking_carrier order.tracking_set_at = datetime.now(UTC) order.sync_status = "shipped" return order # ========================================================================= # 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. Returns a tuple of (logs, total_count). """ 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. Returns a tuple of (items, total_count). """ 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 # ========================================================================= # Unified Jobs Operations # ========================================================================= def list_letzshop_jobs( self, vendor_id: int, 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. Combines product imports from marketplace_import_jobs and order syncs from letzshop_sync_logs. Args: vendor_id: Vendor ID job_type: Filter by type ('import', 'order_sync', or None for all) status: Filter by status skip: Pagination offset limit: Pagination limit Returns: Tuple of (jobs_list, total_count) where jobs_list contains dicts with id, type, status, created_at, started_at, completed_at, records_processed, records_succeeded, records_failed. """ jobs = [] # Product imports from marketplace_import_jobs if job_type in (None, "import"): import_query = self.db.query(MarketplaceImportJob).filter( MarketplaceImportJob.vendor_id == vendor_id, MarketplaceImportJob.marketplace == "Letzshop", ) 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: 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, } ) # Order syncs from letzshop_sync_logs if job_type in (None, "order_sync"): sync_query = self.db.query(LetzshopSyncLog).filter( LetzshopSyncLog.vendor_id == vendor_id, LetzshopSyncLog.operation_type == "order_import", ) 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: 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, } ) # Sort all jobs by created_at descending jobs.sort(key=lambda x: x["created_at"], reverse=True) # Get total count and apply pagination total = len(jobs) jobs = jobs[skip : skip + limit] return jobs, total