# app/api/v1/vendor/letzshop.py """ Vendor API endpoints for Letzshop marketplace integration. Provides vendor-level management of: - Letzshop credentials - Connection testing - Order import and sync - Fulfillment operations (confirm, reject, tracking) Vendor Context: Uses token_vendor_id from JWT token. """ import logging from fastapi import APIRouter, Depends, Path, Query from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db from app.exceptions import ResourceNotFoundException, ValidationException from app.services.letzshop import ( CredentialsNotFoundError, LetzshopClientError, LetzshopCredentialsService, LetzshopOrderService, OrderNotFoundError, ) from models.database.user import User from models.schema.letzshop import ( FulfillmentConfirmRequest, FulfillmentOperationResponse, FulfillmentQueueItemResponse, FulfillmentQueueListResponse, FulfillmentRejectRequest, FulfillmentTrackingRequest, LetzshopConnectionTestRequest, LetzshopConnectionTestResponse, LetzshopCredentialsCreate, LetzshopCredentialsResponse, LetzshopCredentialsStatus, LetzshopCredentialsUpdate, LetzshopOrderDetailResponse, LetzshopOrderListResponse, LetzshopOrderResponse, LetzshopSuccessResponse, LetzshopSyncLogListResponse, LetzshopSyncLogResponse, LetzshopSyncTriggerRequest, LetzshopSyncTriggerResponse, ) 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) # ============================================================================ # Status & Configuration # ============================================================================ @router.get("/status", response_model=LetzshopCredentialsStatus) def get_letzshop_status( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get Letzshop integration status for the current vendor.""" creds_service = get_credentials_service(db) status = creds_service.get_status(current_user.token_vendor_id) return LetzshopCredentialsStatus(**status) @router.get("/credentials", response_model=LetzshopCredentialsResponse) def get_credentials( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get Letzshop credentials for the current vendor (API key is masked).""" creds_service = get_credentials_service(db) vendor_id = current_user.token_vendor_id try: credentials = creds_service.get_credentials_or_raise(vendor_id) except CredentialsNotFoundError: raise ResourceNotFoundException("LetzshopCredentials", str(vendor_id)) 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("/credentials", response_model=LetzshopCredentialsResponse) def save_credentials( credentials_data: LetzshopCredentialsCreate, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Create or update Letzshop credentials for the current vendor.""" creds_service = get_credentials_service(db) vendor_id = current_user.token_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"Vendor user {current_user.email} updated Letzshop credentials") 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("/credentials", response_model=LetzshopCredentialsResponse) def update_credentials( credentials_data: LetzshopCredentialsUpdate, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Partially update Letzshop credentials for the current vendor.""" creds_service = get_credentials_service(db) vendor_id = current_user.token_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)) 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("/credentials", response_model=LetzshopSuccessResponse) def delete_credentials( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Delete Letzshop credentials for the current vendor.""" creds_service = get_credentials_service(db) deleted = creds_service.delete_credentials(current_user.token_vendor_id) if not deleted: raise ResourceNotFoundException( "LetzshopCredentials", str(current_user.token_vendor_id) ) db.commit() logger.info(f"Vendor user {current_user.email} deleted Letzshop credentials") return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted") # ============================================================================ # Connection Testing # ============================================================================ @router.post("/test", response_model=LetzshopConnectionTestResponse) def test_connection( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Test the Letzshop connection using stored credentials.""" creds_service = get_credentials_service(db) success, response_time_ms, error = creds_service.test_connection( current_user.token_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-key", response_model=LetzshopConnectionTestResponse) def test_api_key( test_request: LetzshopConnectionTestRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """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_orders( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), sync_status: str | None = Query(None, description="Filter by sync status"), letzshop_state: str | None = Query(None, description="Filter by Letzshop state"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """List Letzshop orders for the current vendor.""" order_service = get_order_service(db) vendor_id = current_user.token_vendor_id orders, total = order_service.list_orders( vendor_id=vendor_id, skip=skip, limit=limit, sync_status=sync_status, letzshop_state=letzshop_state, ) return LetzshopOrderListResponse( orders=[ LetzshopOrderResponse( id=order.id, vendor_id=order.vendor_id, letzshop_order_id=order.letzshop_order_id, letzshop_shipment_id=order.letzshop_shipment_id, letzshop_order_number=order.letzshop_order_number, letzshop_state=order.letzshop_state, customer_email=order.customer_email, customer_name=order.customer_name, total_amount=order.total_amount, currency=order.currency, local_order_id=order.local_order_id, sync_status=order.sync_status, last_synced_at=order.last_synced_at, sync_error=order.sync_error, confirmed_at=order.confirmed_at, rejected_at=order.rejected_at, tracking_set_at=order.tracking_set_at, tracking_number=order.tracking_number, tracking_carrier=order.tracking_carrier, inventory_units=order.inventory_units, created_at=order.created_at, updated_at=order.updated_at, ) for order in orders ], total=total, skip=skip, limit=limit, ) @router.get("/orders/{order_id}", response_model=LetzshopOrderDetailResponse) def get_order( order_id: int = Path(..., description="Order ID"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get a specific Letzshop order with full details.""" order_service = get_order_service(db) try: order = order_service.get_order_or_raise(current_user.token_vendor_id, order_id) except OrderNotFoundError: raise ResourceNotFoundException("LetzshopOrder", str(order_id)) return LetzshopOrderDetailResponse( id=order.id, vendor_id=order.vendor_id, letzshop_order_id=order.letzshop_order_id, letzshop_shipment_id=order.letzshop_shipment_id, letzshop_order_number=order.letzshop_order_number, letzshop_state=order.letzshop_state, customer_email=order.customer_email, customer_name=order.customer_name, total_amount=order.total_amount, currency=order.currency, local_order_id=order.local_order_id, sync_status=order.sync_status, last_synced_at=order.last_synced_at, sync_error=order.sync_error, confirmed_at=order.confirmed_at, rejected_at=order.rejected_at, tracking_set_at=order.tracking_set_at, tracking_number=order.tracking_number, tracking_carrier=order.tracking_carrier, inventory_units=order.inventory_units, raw_order_data=order.raw_order_data, created_at=order.created_at, updated_at=order.updated_at, ) @router.post("/orders/import", response_model=LetzshopSyncTriggerResponse) def import_orders( sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Import new orders from Letzshop.""" vendor_id = current_user.token_vendor_id order_service = get_order_service(db) creds_service = get_credentials_service(db) # Verify credentials exist try: creds_service.get_credentials_or_raise(vendor_id) except CredentialsNotFoundError: raise ValidationException("Letzshop credentials not configured") # Import orders try: with creds_service.create_client(vendor_id) as client: shipments = client.get_unconfirmed_shipments() orders_imported = 0 orders_updated = 0 errors = [] for shipment in shipments: try: existing = order_service.get_order_by_shipment_id( vendor_id, shipment["id"] ) if existing: order_service.update_order_from_shipment(existing, shipment) orders_updated += 1 else: 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() 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"Import 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"Import failed: {e}", errors=[str(e)], ) # ============================================================================ # Fulfillment Operations # ============================================================================ @router.post("/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse) def confirm_order( order_id: int = Path(..., description="Order ID"), confirm_request: FulfillmentConfirmRequest | None = None, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Confirm inventory units for a Letzshop order.""" vendor_id = current_user.token_vendor_id 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("LetzshopOrder", str(order_id)) # Get inventory unit IDs from request or order if confirm_request and confirm_request.inventory_unit_ids: inventory_unit_ids = confirm_request.inventory_unit_ids elif order.inventory_units: inventory_unit_ids = [u["id"] for u in order.inventory_units] else: raise ValidationException("No inventory units to confirm") try: with creds_service.create_client(vendor_id) as client: result = client.confirm_inventory_units(inventory_unit_ids) # Check for errors if result.get("errors"): error_messages = [ f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}" for e in result["errors"] ] return FulfillmentOperationResponse( success=False, message="Some inventory units could not be confirmed", errors=error_messages, ) # Update order status 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("/orders/{order_id}/reject", response_model=FulfillmentOperationResponse) def reject_order( order_id: int = Path(..., description="Order ID"), reject_request: FulfillmentRejectRequest | None = None, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Reject inventory units for a Letzshop order.""" vendor_id = current_user.token_vendor_id 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("LetzshopOrder", str(order_id)) # Get inventory unit IDs from request or order if reject_request and reject_request.inventory_unit_ids: inventory_unit_ids = reject_request.inventory_unit_ids elif order.inventory_units: inventory_unit_ids = [u["id"] for u in order.inventory_units] else: raise ValidationException("No inventory units to reject") try: with creds_service.create_client(vendor_id) as client: result = client.reject_inventory_units(inventory_unit_ids) if result.get("errors"): error_messages = [ f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}" for e in result["errors"] ] return FulfillmentOperationResponse( success=False, message="Some inventory units could not be rejected", errors=error_messages, ) order_service.mark_order_rejected(order) db.commit() return FulfillmentOperationResponse( success=True, message=f"Rejected {len(inventory_unit_ids)} inventory units", ) except LetzshopClientError as e: return FulfillmentOperationResponse(success=False, message=str(e)) @router.post("/orders/{order_id}/tracking", response_model=FulfillmentOperationResponse) def set_order_tracking( order_id: int = Path(..., description="Order ID"), tracking_request: FulfillmentTrackingRequest = ..., current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Set tracking information for a Letzshop order.""" vendor_id = current_user.token_vendor_id 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("LetzshopOrder", str(order_id)) if not order.letzshop_shipment_id: raise ValidationException("Order does not have a shipment ID") try: with creds_service.create_client(vendor_id) as client: result = client.set_shipment_tracking( shipment_id=order.letzshop_shipment_id, tracking_code=tracking_request.tracking_number, tracking_provider=tracking_request.tracking_carrier, ) if result.get("errors"): error_messages = [ f"{e.get('code', 'unknown')}: {e.get('message', 'Unknown error')}" for e in result["errors"] ] return FulfillmentOperationResponse( success=False, message="Failed to set tracking", errors=error_messages, ) order_service.set_order_tracking( order, tracking_request.tracking_number, tracking_request.tracking_carrier, ) db.commit() return FulfillmentOperationResponse( success=True, message="Tracking information set", tracking_number=tracking_request.tracking_number, tracking_carrier=tracking_request.tracking_carrier, ) except LetzshopClientError as e: return FulfillmentOperationResponse(success=False, message=str(e)) # ============================================================================ # Sync Logs # ============================================================================ @router.get("/logs", response_model=LetzshopSyncLogListResponse) def list_sync_logs( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """List Letzshop sync logs for the current vendor.""" order_service = get_order_service(db) vendor_id = current_user.token_vendor_id logs, total = order_service.list_sync_logs( vendor_id=vendor_id, skip=skip, limit=limit, ) return LetzshopSyncLogListResponse( logs=[ LetzshopSyncLogResponse( id=log.id, vendor_id=log.vendor_id, operation_type=log.operation_type, direction=log.direction, status=log.status, records_processed=log.records_processed, records_succeeded=log.records_succeeded, records_failed=log.records_failed, error_details=log.error_details, started_at=log.started_at, completed_at=log.completed_at, duration_seconds=log.duration_seconds, triggered_by=log.triggered_by, created_at=log.created_at, ) for log in logs ], total=total, skip=skip, limit=limit, ) # ============================================================================ # Fulfillment Queue # ============================================================================ @router.get("/queue", response_model=FulfillmentQueueListResponse) def list_fulfillment_queue( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), status: str | None = Query(None, description="Filter by status"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """List fulfillment queue items for the current vendor.""" order_service = get_order_service(db) vendor_id = current_user.token_vendor_id items, total = order_service.list_fulfillment_queue( vendor_id=vendor_id, skip=skip, limit=limit, status=status, ) return FulfillmentQueueListResponse( items=[ FulfillmentQueueItemResponse( id=item.id, vendor_id=item.vendor_id, letzshop_order_id=item.letzshop_order_id, operation=item.operation, payload=item.payload, status=item.status, attempts=item.attempts, max_attempts=item.max_attempts, last_attempt_at=item.last_attempt_at, next_retry_at=item.next_retry_at, error_message=item.error_message, completed_at=item.completed_at, response_data=item.response_data, created_at=item.created_at, updated_at=item.updated_at, ) for item in items ], total=total, skip=skip, limit=limit, ) # ============================================================================ # Product Export # ============================================================================ @router.get("/export") def export_products_letzshop( language: str = Query( "en", description="Language for title/description (en, fr, de)" ), include_inactive: bool = Query(False, description="Include inactive products"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """ Export vendor products in Letzshop CSV format. Generates a Google Shopping compatible CSV file for Letzshop marketplace. The file uses tab-separated values and includes all required Letzshop fields. **Supported languages:** en, fr, de **CSV Format:** - Delimiter: Tab (\\t) - Encoding: UTF-8 - Fields: id, title, description, price, availability, image_link, etc. Returns: CSV file as attachment (vendor_code_letzshop_export.csv) """ from fastapi.responses import Response from app.services.letzshop_export_service import letzshop_export_service from app.services.vendor_service import vendor_service vendor_id = current_user.token_vendor_id vendor = vendor_service.get_vendor_by_id(db, vendor_id) csv_content = letzshop_export_service.export_vendor_products( db=db, vendor_id=vendor_id, language=language, include_inactive=include_inactive, ) filename = f"{vendor.vendor_code.lower()}_letzshop_export.csv" return Response( content=csv_content, media_type="text/csv; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{filename}"', }, )