# app/api/v1/admin/letzshop.py """ Admin API endpoints for Letzshop marketplace integration. Provides admin-level management of: - Per-vendor Letzshop credentials - Connection testing - Sync triggers and status - Order overview """ import logging from fastapi import APIRouter, BackgroundTasks, Depends, Path, Query from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db from app.exceptions import ( OrderHasUnresolvedExceptionsException, ResourceNotFoundException, ValidationException, ) from app.services.order_item_exception_service import order_item_exception_service from app.services.letzshop import ( CredentialsNotFoundError, LetzshopClientError, LetzshopCredentialsService, LetzshopOrderService, OrderNotFoundError, VendorNotFoundError, ) from app.tasks.letzshop_tasks import process_historical_import from models.database.user import User from models.schema.letzshop import ( FulfillmentOperationResponse, LetzshopConnectionTestRequest, LetzshopConnectionTestResponse, LetzshopCredentialsCreate, LetzshopCredentialsResponse, LetzshopCredentialsUpdate, LetzshopHistoricalImportJobResponse, LetzshopHistoricalImportStartResponse, LetzshopJobItem, LetzshopJobsListResponse, LetzshopOrderDetailResponse, LetzshopOrderItemResponse, LetzshopOrderListResponse, LetzshopOrderResponse, LetzshopOrderStats, LetzshopSuccessResponse, LetzshopSyncTriggerRequest, LetzshopSyncTriggerResponse, LetzshopVendorListResponse, LetzshopVendorOverview, ) router = APIRouter(prefix="/letzshop") logger = logging.getLogger(__name__) # ============================================================================ # Helper Functions # ============================================================================ def get_order_service(db: Session) -> LetzshopOrderService: """Get order service instance.""" return LetzshopOrderService(db) def get_credentials_service(db: Session) -> LetzshopCredentialsService: """Get credentials service instance.""" return LetzshopCredentialsService(db) # ============================================================================ # Vendor Overview # ============================================================================ @router.get("/vendors", response_model=LetzshopVendorListResponse) def list_vendors_letzshop_status( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), configured_only: bool = Query( False, description="Only show vendors with Letzshop configured" ), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ List all vendors with their Letzshop integration status. Shows which vendors have Letzshop configured, sync status, and pending orders. """ order_service = get_order_service(db) vendor_overviews, total = order_service.list_vendors_with_letzshop_status( skip=skip, limit=limit, configured_only=configured_only, ) return LetzshopVendorListResponse( vendors=[LetzshopVendorOverview(**v) for v in vendor_overviews], total=total, skip=skip, limit=limit, ) # ============================================================================ # Credentials Management # ============================================================================ @router.get( "/vendors/{vendor_id}/credentials", response_model=LetzshopCredentialsResponse, ) def get_vendor_credentials( vendor_id: int = Path(..., description="Vendor ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Get Letzshop credentials for a vendor (API key is masked).""" order_service = get_order_service(db) creds_service = get_credentials_service(db) try: vendor = order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) try: credentials = creds_service.get_credentials_or_raise(vendor_id) except CredentialsNotFoundError: raise ResourceNotFoundException( "LetzshopCredentials", str(vendor_id), message=f"Letzshop credentials not configured for vendor {vendor.name}", ) return LetzshopCredentialsResponse( id=credentials.id, vendor_id=credentials.vendor_id, api_key_masked=creds_service.get_masked_api_key(vendor_id), api_endpoint=credentials.api_endpoint, auto_sync_enabled=credentials.auto_sync_enabled, sync_interval_minutes=credentials.sync_interval_minutes, last_sync_at=credentials.last_sync_at, last_sync_status=credentials.last_sync_status, last_sync_error=credentials.last_sync_error, created_at=credentials.created_at, updated_at=credentials.updated_at, ) @router.post( "/vendors/{vendor_id}/credentials", response_model=LetzshopCredentialsResponse, ) def create_or_update_vendor_credentials( vendor_id: int = Path(..., description="Vendor ID"), credentials_data: LetzshopCredentialsCreate = ..., db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Create or update Letzshop credentials for a vendor.""" order_service = get_order_service(db) creds_service = get_credentials_service(db) try: vendor = order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) credentials = creds_service.upsert_credentials( vendor_id=vendor_id, api_key=credentials_data.api_key, api_endpoint=credentials_data.api_endpoint, auto_sync_enabled=credentials_data.auto_sync_enabled, sync_interval_minutes=credentials_data.sync_interval_minutes, ) db.commit() logger.info( f"Admin {current_admin.email} updated Letzshop credentials for vendor {vendor.name}" ) return LetzshopCredentialsResponse( id=credentials.id, vendor_id=credentials.vendor_id, api_key_masked=creds_service.get_masked_api_key(vendor_id), api_endpoint=credentials.api_endpoint, auto_sync_enabled=credentials.auto_sync_enabled, sync_interval_minutes=credentials.sync_interval_minutes, last_sync_at=credentials.last_sync_at, last_sync_status=credentials.last_sync_status, last_sync_error=credentials.last_sync_error, created_at=credentials.created_at, updated_at=credentials.updated_at, ) @router.patch( "/vendors/{vendor_id}/credentials", response_model=LetzshopCredentialsResponse, ) def update_vendor_credentials( vendor_id: int = Path(..., description="Vendor ID"), credentials_data: LetzshopCredentialsUpdate = ..., db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Partially update Letzshop credentials for a vendor.""" order_service = get_order_service(db) creds_service = get_credentials_service(db) try: vendor = order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) try: credentials = creds_service.update_credentials( vendor_id=vendor_id, api_key=credentials_data.api_key, api_endpoint=credentials_data.api_endpoint, auto_sync_enabled=credentials_data.auto_sync_enabled, sync_interval_minutes=credentials_data.sync_interval_minutes, ) db.commit() except CredentialsNotFoundError: raise ResourceNotFoundException( "LetzshopCredentials", str(vendor_id), message=f"Letzshop credentials not configured for vendor {vendor.name}", ) return LetzshopCredentialsResponse( id=credentials.id, vendor_id=credentials.vendor_id, api_key_masked=creds_service.get_masked_api_key(vendor_id), api_endpoint=credentials.api_endpoint, auto_sync_enabled=credentials.auto_sync_enabled, sync_interval_minutes=credentials.sync_interval_minutes, last_sync_at=credentials.last_sync_at, last_sync_status=credentials.last_sync_status, last_sync_error=credentials.last_sync_error, created_at=credentials.created_at, updated_at=credentials.updated_at, ) @router.delete( "/vendors/{vendor_id}/credentials", response_model=LetzshopSuccessResponse, ) def delete_vendor_credentials( vendor_id: int = Path(..., description="Vendor ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Delete Letzshop credentials for a vendor.""" order_service = get_order_service(db) creds_service = get_credentials_service(db) try: vendor = order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) deleted = creds_service.delete_credentials(vendor_id) if not deleted: raise ResourceNotFoundException( "LetzshopCredentials", str(vendor_id), message=f"Letzshop credentials not configured for vendor {vendor.name}", ) db.commit() logger.info( f"Admin {current_admin.email} deleted Letzshop credentials for vendor {vendor.name}" ) return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted") # ============================================================================ # Connection Testing # ============================================================================ @router.post( "/vendors/{vendor_id}/test", response_model=LetzshopConnectionTestResponse, ) def test_vendor_connection( vendor_id: int = Path(..., description="Vendor ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Test the Letzshop connection for a vendor using stored credentials.""" order_service = get_order_service(db) creds_service = get_credentials_service(db) try: order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) success, response_time_ms, error = creds_service.test_connection(vendor_id) return LetzshopConnectionTestResponse( success=success, message="Connection successful" if success else "Connection failed", response_time_ms=response_time_ms, error_details=error, ) @router.post("/test", response_model=LetzshopConnectionTestResponse) def test_api_key( test_request: LetzshopConnectionTestRequest, db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Test a Letzshop API key without saving it.""" creds_service = get_credentials_service(db) success, response_time_ms, error = creds_service.test_api_key( api_key=test_request.api_key, api_endpoint=test_request.api_endpoint, ) return LetzshopConnectionTestResponse( success=success, message="Connection successful" if success else "Connection failed", response_time_ms=response_time_ms, error_details=error, ) # ============================================================================ # Order Management # ============================================================================ @router.get( "/orders", response_model=LetzshopOrderListResponse, ) def list_all_letzshop_orders( vendor_id: int | None = Query(None, description="Filter by vendor"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), status: str | None = Query(None, description="Filter by order status"), has_declined_items: bool | None = Query( None, description="Filter orders with declined/unavailable items" ), search: str | None = Query( None, description="Search by order number, customer name, or email" ), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ List Letzshop orders across all vendors (or for a specific vendor). When vendor_id is not provided, returns orders from all vendors. """ order_service = get_order_service(db) orders, total = order_service.list_orders( vendor_id=vendor_id, skip=skip, limit=limit, status=status, has_declined_items=has_declined_items, search=search, ) # Get order stats (cross-vendor or vendor-specific) stats = order_service.get_order_stats(vendor_id) return LetzshopOrderListResponse( orders=[ LetzshopOrderResponse( id=order.id, vendor_id=order.vendor_id, vendor_name=order.vendor.name if order.vendor else None, order_number=order.order_number, external_order_id=order.external_order_id, external_shipment_id=order.external_shipment_id, external_order_number=order.external_order_number, status=order.status, customer_email=order.customer_email, customer_name=order.customer_full_name, customer_locale=order.customer_locale, ship_country_iso=order.ship_country_iso, bill_country_iso=order.bill_country_iso, total_amount=order.total_amount, currency=order.currency, tracking_number=order.tracking_number, tracking_provider=order.tracking_provider, order_date=order.order_date, confirmed_at=order.confirmed_at, shipped_at=order.shipped_at, cancelled_at=order.cancelled_at, created_at=order.created_at, updated_at=order.updated_at, items=[ LetzshopOrderItemResponse( id=item.id, product_id=item.product_id, product_name=item.product_name, product_sku=item.product_sku, gtin=item.gtin, gtin_type=item.gtin_type, quantity=item.quantity, unit_price=item.unit_price, total_price=item.total_price, external_item_id=item.external_item_id, external_variant_id=item.external_variant_id, item_state=item.item_state, ) for item in order.items ], ) for order in orders ], total=total, skip=skip, limit=limit, stats=LetzshopOrderStats(**stats), ) @router.get( "/vendors/{vendor_id}/orders", response_model=LetzshopOrderListResponse, ) def list_vendor_letzshop_orders( vendor_id: int = Path(..., description="Vendor ID"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), status: str | None = Query(None, description="Filter by order status"), has_declined_items: bool | None = Query( None, description="Filter orders with declined/unavailable items" ), search: str | None = Query( None, description="Search by order number, customer name, or email" ), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """List Letzshop orders for a vendor.""" order_service = get_order_service(db) try: order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) orders, total = order_service.list_orders( vendor_id=vendor_id, skip=skip, limit=limit, status=status, has_declined_items=has_declined_items, search=search, ) # Get order stats for all statuses stats = order_service.get_order_stats(vendor_id) return LetzshopOrderListResponse( orders=[ LetzshopOrderResponse( id=order.id, vendor_id=order.vendor_id, order_number=order.order_number, external_order_id=order.external_order_id, external_shipment_id=order.external_shipment_id, external_order_number=order.external_order_number, status=order.status, customer_email=order.customer_email, customer_name=order.customer_full_name, customer_locale=order.customer_locale, ship_country_iso=order.ship_country_iso, bill_country_iso=order.bill_country_iso, total_amount=order.total_amount, currency=order.currency, tracking_number=order.tracking_number, tracking_provider=order.tracking_provider, order_date=order.order_date, confirmed_at=order.confirmed_at, shipped_at=order.shipped_at, cancelled_at=order.cancelled_at, created_at=order.created_at, updated_at=order.updated_at, items=[ LetzshopOrderItemResponse( id=item.id, product_id=item.product_id, product_name=item.product_name, product_sku=item.product_sku, gtin=item.gtin, gtin_type=item.gtin_type, quantity=item.quantity, unit_price=item.unit_price, total_price=item.total_price, external_item_id=item.external_item_id, external_variant_id=item.external_variant_id, item_state=item.item_state, ) for item in order.items ], ) for order in orders ], total=total, skip=skip, limit=limit, stats=LetzshopOrderStats(**stats), ) @router.get( "/orders/{order_id}", response_model=LetzshopOrderDetailResponse, ) def get_letzshop_order_detail( order_id: int = Path(..., description="Letzshop order ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Get detailed information for a single Letzshop order.""" order_service = get_order_service(db) order = order_service.get_order_by_id(order_id) if not order: raise ResourceNotFoundException("Order", str(order_id)) return LetzshopOrderDetailResponse( id=order.id, vendor_id=order.vendor_id, order_number=order.order_number, external_order_id=order.external_order_id, external_shipment_id=order.external_shipment_id, external_order_number=order.external_order_number, status=order.status, customer_email=order.customer_email, customer_name=order.customer_full_name, customer_locale=order.customer_locale, customer_first_name=order.customer_first_name, customer_last_name=order.customer_last_name, customer_phone=order.customer_phone, ship_country_iso=order.ship_country_iso, ship_first_name=order.ship_first_name, ship_last_name=order.ship_last_name, ship_company=order.ship_company, ship_address_line_1=order.ship_address_line_1, ship_address_line_2=order.ship_address_line_2, ship_city=order.ship_city, ship_postal_code=order.ship_postal_code, bill_country_iso=order.bill_country_iso, bill_first_name=order.bill_first_name, bill_last_name=order.bill_last_name, bill_company=order.bill_company, bill_address_line_1=order.bill_address_line_1, bill_address_line_2=order.bill_address_line_2, bill_city=order.bill_city, bill_postal_code=order.bill_postal_code, total_amount=order.total_amount, currency=order.currency, tracking_number=order.tracking_number, tracking_provider=order.tracking_provider, order_date=order.order_date, confirmed_at=order.confirmed_at, shipped_at=order.shipped_at, cancelled_at=order.cancelled_at, created_at=order.created_at, updated_at=order.updated_at, external_data=order.external_data, customer_notes=order.customer_notes, internal_notes=order.internal_notes, items=[ LetzshopOrderItemResponse( id=item.id, product_id=item.product_id, product_name=item.product_name, product_sku=item.product_sku, gtin=item.gtin, gtin_type=item.gtin_type, quantity=item.quantity, unit_price=item.unit_price, total_price=item.total_price, external_item_id=item.external_item_id, external_variant_id=item.external_variant_id, item_state=item.item_state, ) for item in order.items ], ) @router.post( "/vendors/{vendor_id}/sync", response_model=LetzshopSyncTriggerResponse, ) def trigger_vendor_sync( vendor_id: int = Path(..., description="Vendor ID"), sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Trigger a sync operation for a vendor. This imports new orders from Letzshop. """ order_service = get_order_service(db) creds_service = get_credentials_service(db) try: vendor = order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) # Verify credentials exist try: creds_service.get_credentials_or_raise(vendor_id) except CredentialsNotFoundError: raise ValidationException( f"Letzshop credentials not configured for vendor {vendor.name}" ) # Import orders using the client try: with creds_service.create_client(vendor_id) as client: shipments = client.get_unconfirmed_shipments() logger.info( f"Letzshop sync for vendor {vendor_id}: " f"fetched {len(shipments)} unconfirmed shipments from API" ) orders_imported = 0 orders_updated = 0 errors = [] for shipment in shipments: try: # Check if order already exists existing = order_service.get_order_by_shipment_id( vendor_id, shipment["id"] ) if existing: # Update existing order order_service.update_order_from_shipment(existing, shipment) orders_updated += 1 else: # Create new order order_service.create_order(vendor_id, shipment) orders_imported += 1 except Exception as e: errors.append( f"Error processing shipment {shipment.get('id')}: {e}" ) db.commit() # Update sync status creds_service.update_sync_status( vendor_id, "success" if not errors else "partial", "; ".join(errors) if errors else None, ) return LetzshopSyncTriggerResponse( success=True, message=f"Sync completed: {orders_imported} imported, {orders_updated} updated", orders_imported=orders_imported, orders_updated=orders_updated, errors=errors, ) except LetzshopClientError as e: creds_service.update_sync_status(vendor_id, "failed", str(e)) return LetzshopSyncTriggerResponse( success=False, message=f"Sync failed: {e}", errors=[str(e)], ) # ============================================================================ # Jobs (Unified view of imports, exports, and syncs) # ============================================================================ @router.get( "/jobs", response_model=LetzshopJobsListResponse, ) def list_all_letzshop_jobs( job_type: str | None = Query(None, description="Filter: import, export, order_sync, historical_import"), status: str | None = Query(None, description="Filter by status"), skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Get unified list of all Letzshop-related jobs across all vendors. Combines product imports, exports, and order syncs. """ order_service = get_order_service(db) jobs_data, total = order_service.list_letzshop_jobs( vendor_id=None, job_type=job_type, status=status, skip=skip, limit=limit, ) jobs = [LetzshopJobItem(**job) for job in jobs_data] return LetzshopJobsListResponse(jobs=jobs, total=total) @router.get( "/vendors/{vendor_id}/jobs", response_model=LetzshopJobsListResponse, ) def list_vendor_letzshop_jobs( vendor_id: int = Path(..., description="Vendor ID"), job_type: str | None = Query(None, description="Filter: import, export, order_sync"), status: str | None = Query(None, description="Filter by status"), skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Get unified list of Letzshop-related jobs for a vendor. Combines product imports, exports, and order syncs. """ order_service = get_order_service(db) try: order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) # Use service layer for database queries jobs_data, total = order_service.list_letzshop_jobs( vendor_id=vendor_id, job_type=job_type, status=status, skip=skip, limit=limit, ) # Convert dict data to Pydantic models jobs = [LetzshopJobItem(**job) for job in jobs_data] return LetzshopJobsListResponse(jobs=jobs, total=total) # ============================================================================ # Historical Import # ============================================================================ @router.post( "/vendors/{vendor_id}/import-history", response_model=LetzshopHistoricalImportStartResponse, ) def start_historical_import( vendor_id: int = Path(..., description="Vendor ID"), background_tasks: BackgroundTasks = None, db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Start historical order import from Letzshop as a background job. Creates a job that imports both confirmed and declined orders with real-time progress tracking. Poll the status endpoint to track progress. Returns a job_id for polling the status endpoint. """ order_service = get_order_service(db) creds_service = get_credentials_service(db) try: vendor = order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) # Verify credentials exist try: creds_service.get_credentials_or_raise(vendor_id) except CredentialsNotFoundError: raise ValidationException( f"Letzshop credentials not configured for vendor {vendor.name}" ) # Check if there's already a running import for this vendor existing_job = order_service.get_running_historical_import_job(vendor_id) if existing_job: raise ValidationException( f"Historical import already in progress (job_id={existing_job.id})" ) # Create job record job = order_service.create_historical_import_job(vendor_id, current_admin.id) logger.info(f"Created historical import job {job.id} for vendor {vendor_id}") # Queue background task background_tasks.add_task( process_historical_import, job.id, vendor_id, ) return LetzshopHistoricalImportStartResponse( job_id=job.id, status="pending", message="Historical import job started", ) @router.get( "/vendors/{vendor_id}/import-history/{job_id}/status", response_model=LetzshopHistoricalImportJobResponse, ) def get_historical_import_status( vendor_id: int = Path(..., description="Vendor ID"), job_id: int = Path(..., description="Import job ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Get status of a historical import job. Poll this endpoint to track import progress. Returns current phase, page being fetched, and counts of processed/imported/updated orders. """ order_service = get_order_service(db) job = order_service.get_historical_import_job_by_id(vendor_id, job_id) if not job: raise ResourceNotFoundException("HistoricalImportJob", str(job_id)) return LetzshopHistoricalImportJobResponse.model_validate(job) @router.get( "/vendors/{vendor_id}/import-summary", ) def get_import_summary( vendor_id: int = Path(..., description="Vendor ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Get summary statistics for imported Letzshop orders. Returns total orders, unique customers, and breakdowns by state/locale/country. """ order_service = get_order_service(db) try: order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) summary = order_service.get_historical_import_summary(vendor_id) return { "success": True, "summary": summary, } # ============================================================================ # Fulfillment Operations (Admin) # ============================================================================ @router.post( "/vendors/{vendor_id}/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse, ) def confirm_order( vendor_id: int = Path(..., description="Vendor ID"), order_id: int = Path(..., description="Order ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Confirm all inventory units for a Letzshop order. Sends confirmInventoryUnits mutation with isAvailable=true for all items. Raises: OrderHasUnresolvedExceptionsException: If order has unresolved product exceptions """ order_service = get_order_service(db) creds_service = get_credentials_service(db) try: order = order_service.get_order_or_raise(vendor_id, order_id) except OrderNotFoundError: raise ResourceNotFoundException("Order", str(order_id)) # Check for unresolved exceptions (blocks confirmation) unresolved_count = order_item_exception_service.get_unresolved_exception_count( db, order_id ) if unresolved_count > 0: raise OrderHasUnresolvedExceptionsException(order_id, unresolved_count) # Get inventory unit IDs from order items items = order_service.get_order_items(order) if not items: return FulfillmentOperationResponse( success=False, message="No items found in order", ) inventory_unit_ids = [ item.external_item_id for item in items if item.external_item_id ] if not inventory_unit_ids: return FulfillmentOperationResponse( success=False, message="No inventory unit IDs found in order items", ) try: with creds_service.create_client(vendor_id) as client: result = client.confirm_inventory_units(inventory_unit_ids) if not result.get("inventoryUnits"): error_messages = [ e.get("message", "Unknown error") for e in result.get("errors", []) ] return FulfillmentOperationResponse( success=False, message="Some inventory units could not be confirmed", errors=error_messages, ) # Update order status and item states for item in items: if item.external_item_id: order_service.update_inventory_unit_state( order, item.external_item_id, "confirmed_available" ) order_service.mark_order_confirmed(order) db.commit() return FulfillmentOperationResponse( success=True, message=f"Confirmed {len(inventory_unit_ids)} inventory units", confirmed_units=[u.get("id") for u in result.get("inventoryUnits", [])], ) except LetzshopClientError as e: return FulfillmentOperationResponse(success=False, message=str(e)) @router.post( "/vendors/{vendor_id}/orders/{order_id}/reject", response_model=FulfillmentOperationResponse, ) def reject_order( vendor_id: int = Path(..., description="Vendor ID"), order_id: int = Path(..., description="Order ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Decline all inventory units for a Letzshop order. Sends confirmInventoryUnits mutation with isAvailable=false for all items. """ order_service = get_order_service(db) creds_service = get_credentials_service(db) try: order = order_service.get_order_or_raise(vendor_id, order_id) except OrderNotFoundError: raise ResourceNotFoundException("Order", str(order_id)) # Get inventory unit IDs from order items items = order_service.get_order_items(order) if not items: return FulfillmentOperationResponse( success=False, message="No items found in order", ) inventory_unit_ids = [ item.external_item_id for item in items if item.external_item_id ] if not inventory_unit_ids: return FulfillmentOperationResponse( success=False, message="No inventory unit IDs found in order items", ) try: with creds_service.create_client(vendor_id) as client: result = client.reject_inventory_units(inventory_unit_ids) if not result.get("inventoryUnits"): error_messages = [ e.get("message", "Unknown error") for e in result.get("errors", []) ] return FulfillmentOperationResponse( success=False, message="Some inventory units could not be declined", errors=error_messages, ) # Update item states and order status for item in items: if item.external_item_id: order_service.update_inventory_unit_state( order, item.external_item_id, "confirmed_unavailable" ) order_service.mark_order_rejected(order) db.commit() return FulfillmentOperationResponse( success=True, message=f"Declined {len(inventory_unit_ids)} inventory units", ) except LetzshopClientError as e: return FulfillmentOperationResponse(success=False, message=str(e)) @router.post( "/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/confirm", response_model=FulfillmentOperationResponse, ) def confirm_single_item( vendor_id: int = Path(..., description="Vendor ID"), order_id: int = Path(..., description="Order ID"), item_id: str = Path(..., description="External Item ID (Letzshop inventory unit ID)"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Confirm a single inventory unit in an order. Sends confirmInventoryUnits mutation with isAvailable=true for one item. Raises: OrderHasUnresolvedExceptionsException: If the specific item has an unresolved exception """ order_service = get_order_service(db) creds_service = get_credentials_service(db) try: order = order_service.get_order_or_raise(vendor_id, order_id) except OrderNotFoundError: raise ResourceNotFoundException("Order", str(order_id)) # Check if this specific item has an unresolved exception # Find the order item by external_item_id item = next( (i for i in order.items if i.external_item_id == item_id), None ) if item and item.needs_product_match: raise OrderHasUnresolvedExceptionsException(order_id, 1) try: with creds_service.create_client(vendor_id) as client: result = client.confirm_inventory_units([item_id]) if not result.get("inventoryUnits"): error_messages = [ e.get("message", "Unknown error") for e in result.get("errors", []) ] return FulfillmentOperationResponse( success=False, message="Failed to confirm item", errors=error_messages, ) # Update local order item state order_service.update_inventory_unit_state(order, item_id, "confirmed_available") db.commit() return FulfillmentOperationResponse( success=True, message="Item confirmed", confirmed_units=[item_id], ) except LetzshopClientError as e: return FulfillmentOperationResponse(success=False, message=str(e)) @router.post( "/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/decline", response_model=FulfillmentOperationResponse, ) def decline_single_item( vendor_id: int = Path(..., description="Vendor ID"), order_id: int = Path(..., description="Order ID"), item_id: str = Path(..., description="External Item ID (Letzshop inventory unit ID)"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Decline a single inventory unit in an order. Sends confirmInventoryUnits mutation with isAvailable=false for one item. """ order_service = get_order_service(db) creds_service = get_credentials_service(db) try: order = order_service.get_order_or_raise(vendor_id, order_id) except OrderNotFoundError: raise ResourceNotFoundException("Order", str(order_id)) try: with creds_service.create_client(vendor_id) as client: result = client.reject_inventory_units([item_id]) if not result.get("inventoryUnits"): error_messages = [ e.get("message", "Unknown error") for e in result.get("errors", []) ] return FulfillmentOperationResponse( success=False, message="Failed to decline item", errors=error_messages, ) # Update local order item state order_service.update_inventory_unit_state(order, item_id, "confirmed_unavailable") db.commit() return FulfillmentOperationResponse( success=True, message="Item declined", ) except LetzshopClientError as e: return FulfillmentOperationResponse(success=False, message=str(e)) # ============================================================================ # Tracking Sync # ============================================================================ @router.post( "/vendors/{vendor_id}/sync-tracking", response_model=LetzshopSyncTriggerResponse, ) def sync_tracking_for_vendor( vendor_id: int = Path(..., description="Vendor ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Sync tracking information from Letzshop for confirmed orders. Fetches tracking data from Letzshop API for orders that: - Are in "processing" status (confirmed) - Don't have tracking info yet - Have an external shipment ID This is useful when tracking is added by Letzshop after order confirmation. """ order_service = get_order_service(db) creds_service = get_credentials_service(db) try: order_service.get_vendor_or_raise(vendor_id) except VendorNotFoundError: raise ResourceNotFoundException("Vendor", str(vendor_id)) # Verify credentials exist try: creds_service.get_credentials_or_raise(vendor_id) except CredentialsNotFoundError: raise ValidationException( f"Letzshop credentials not configured for vendor {vendor_id}" ) # Get orders that need tracking orders_without_tracking = order_service.get_orders_without_tracking(vendor_id) if not orders_without_tracking: return LetzshopSyncTriggerResponse( success=True, message="No orders need tracking updates", orders_imported=0, orders_updated=0, ) logger.info( f"Syncing tracking for {len(orders_without_tracking)} orders (vendor {vendor_id})" ) orders_updated = 0 errors = [] try: with creds_service.create_client(vendor_id) as client: for order in orders_without_tracking: try: # Fetch shipment by ID shipment_data = client.get_shipment_by_id(order.external_shipment_id) if shipment_data: updated = order_service.update_tracking_from_shipment_data( order, shipment_data ) if updated: orders_updated += 1 except Exception as e: errors.append( f"Error syncing tracking for order {order.order_number}: {e}" ) db.commit() message = f"Tracking sync completed: {orders_updated} orders updated" if errors: message += f" ({len(errors)} errors)" return LetzshopSyncTriggerResponse( success=True, message=message, orders_imported=0, orders_updated=orders_updated, errors=errors, ) except LetzshopClientError as e: return LetzshopSyncTriggerResponse( success=False, message=f"Tracking sync failed: {e}", errors=[str(e)], )