# app/tasks/letzshop_tasks.py """Background tasks for Letzshop historical order imports.""" import logging from datetime import UTC, datetime from typing import Callable from app.core.database import SessionLocal from app.services.admin_notification_service import admin_notification_service from app.services.letzshop import LetzshopClientError from app.services.letzshop.credentials_service import LetzshopCredentialsService from app.services.letzshop.order_service import LetzshopOrderService from models.database.letzshop import LetzshopHistoricalImportJob logger = logging.getLogger(__name__) def _get_credentials_service(db) -> LetzshopCredentialsService: """Create a credentials service instance.""" return LetzshopCredentialsService(db) def _get_order_service(db) -> LetzshopOrderService: """Create an order service instance.""" return LetzshopOrderService(db) def process_historical_import(job_id: int, vendor_id: int): """ Background task for historical order import with progress tracking. Imports both confirmed and declined orders from Letzshop API, updating job progress in the database for frontend polling. Args: job_id: ID of the LetzshopHistoricalImportJob record vendor_id: ID of the vendor to import orders for """ db = SessionLocal() job = None try: # Get the import job job = ( db.query(LetzshopHistoricalImportJob) .filter(LetzshopHistoricalImportJob.id == job_id) .first() ) if not job: logger.error(f"Historical import job {job_id} not found") return # Mark as started job.status = "fetching" job.started_at = datetime.now(UTC) db.commit() creds_service = _get_credentials_service(db) order_service = _get_order_service(db) # Create progress callback for fetching def fetch_progress_callback(page: int, total_fetched: int): """Update fetch progress in database.""" job.current_page = page job.shipments_fetched = total_fetched db.commit() # Create progress callback for processing def create_processing_callback( phase: str, ) -> Callable[[int, int, int, int], None]: """Create a processing progress callback for a phase.""" def callback(processed: int, imported: int, updated: int, skipped: int): job.orders_processed = processed job.orders_imported = imported job.orders_updated = updated job.orders_skipped = skipped db.commit() return callback with creds_service.create_client(vendor_id) as client: # ================================================================ # Phase 1: Import confirmed orders # ================================================================ job.current_phase = "confirmed" job.current_page = 0 job.shipments_fetched = 0 db.commit() logger.info(f"Job {job_id}: Fetching confirmed shipments for vendor {vendor_id}") confirmed_shipments = client.get_all_shipments_paginated( state="confirmed", page_size=50, progress_callback=fetch_progress_callback, ) logger.info(f"Job {job_id}: Fetched {len(confirmed_shipments)} confirmed shipments") # Process confirmed shipments job.status = "processing" job.orders_processed = 0 job.orders_imported = 0 job.orders_updated = 0 job.orders_skipped = 0 db.commit() confirmed_stats = order_service.import_historical_shipments( vendor_id=vendor_id, shipments=confirmed_shipments, match_products=True, progress_callback=create_processing_callback("confirmed"), ) # Store confirmed stats job.confirmed_stats = { "total": confirmed_stats["total"], "imported": confirmed_stats["imported"], "updated": confirmed_stats["updated"], "skipped": confirmed_stats["skipped"], "products_matched": confirmed_stats["products_matched"], "products_not_found": confirmed_stats["products_not_found"], } job.products_matched = confirmed_stats["products_matched"] job.products_not_found = confirmed_stats["products_not_found"] db.commit() logger.info( f"Job {job_id}: Confirmed phase complete - " f"imported={confirmed_stats['imported']}, " f"updated={confirmed_stats['updated']}, " f"skipped={confirmed_stats['skipped']}" ) # ================================================================ # Phase 2: Import unconfirmed (pending) orders # Note: Letzshop API has no "declined" state. Declined items # are tracked at the inventory unit level, not shipment level. # Valid states: unconfirmed, confirmed, completed, accepted # ================================================================ job.current_phase = "unconfirmed" job.status = "fetching" job.current_page = 0 job.shipments_fetched = 0 db.commit() logger.info(f"Job {job_id}: Fetching unconfirmed shipments for vendor {vendor_id}") unconfirmed_shipments = client.get_all_shipments_paginated( state="unconfirmed", page_size=50, progress_callback=fetch_progress_callback, ) logger.info(f"Job {job_id}: Fetched {len(unconfirmed_shipments)} unconfirmed shipments") # Process unconfirmed shipments job.status = "processing" job.orders_processed = 0 db.commit() unconfirmed_stats = order_service.import_historical_shipments( vendor_id=vendor_id, shipments=unconfirmed_shipments, match_products=True, progress_callback=create_processing_callback("unconfirmed"), ) # Store unconfirmed stats (in declined_stats field for compatibility) job.declined_stats = { "total": unconfirmed_stats["total"], "imported": unconfirmed_stats["imported"], "updated": unconfirmed_stats["updated"], "skipped": unconfirmed_stats["skipped"], "products_matched": unconfirmed_stats["products_matched"], "products_not_found": unconfirmed_stats["products_not_found"], } # Add to cumulative product matching stats job.products_matched += unconfirmed_stats["products_matched"] job.products_not_found += unconfirmed_stats["products_not_found"] logger.info( f"Job {job_id}: Unconfirmed phase complete - " f"imported={unconfirmed_stats['imported']}, " f"updated={unconfirmed_stats['updated']}, " f"skipped={unconfirmed_stats['skipped']}" ) # ================================================================ # Complete # ================================================================ job.status = "completed" job.completed_at = datetime.now(UTC) db.commit() # Update credentials sync status creds_service.update_sync_status(vendor_id, "success", None) logger.info(f"Job {job_id}: Historical import completed successfully") except LetzshopClientError as e: logger.error(f"Job {job_id}: Letzshop API error: {e}") if job is not None: try: job.status = "failed" job.error_message = f"Letzshop API error: {e}" job.completed_at = datetime.now(UTC) # Get vendor name for notification order_service = _get_order_service(db) vendor = order_service.get_vendor(vendor_id) vendor_name = vendor.name if vendor else f"Vendor {vendor_id}" # Create admin notification for sync failure admin_notification_service.notify_order_sync_failure( db=db, vendor_name=vendor_name, error_message=f"Historical import failed: {str(e)[:150]}", vendor_id=vendor_id, ) db.commit() creds_service = _get_credentials_service(db) creds_service.update_sync_status(vendor_id, "failed", str(e)) except Exception as commit_error: logger.error(f"Job {job_id}: Failed to update job status: {commit_error}") db.rollback() except Exception as e: logger.error(f"Job {job_id}: Unexpected error: {e}", exc_info=True) if job is not None: try: job.status = "failed" job.error_message = str(e) job.completed_at = datetime.now(UTC) # Get vendor name for notification order_service = _get_order_service(db) vendor = order_service.get_vendor(vendor_id) vendor_name = vendor.name if vendor else f"Vendor {vendor_id}" # Create admin notification for critical error admin_notification_service.notify_critical_error( db=db, error_type="Historical Import", error_message=f"Import job {job_id} failed for {vendor_name}: {str(e)[:150]}", details={"job_id": job_id, "vendor_id": vendor_id, "vendor_name": vendor_name}, ) db.commit() except Exception as commit_error: logger.error(f"Job {job_id}: Failed to update job status: {commit_error}") db.rollback() finally: if hasattr(db, "close") and callable(db.close): try: db.close() except Exception as close_error: logger.error(f"Job {job_id}: Error closing database session: {close_error}")