- Add LetzshopVendorCache model to store cached vendor data from Letzshop API - Create LetzshopVendorSyncService for syncing vendor directory - Add Celery task for background vendor sync - Create admin page at /admin/letzshop/vendor-directory with: - Stats dashboard (total, claimed, unclaimed vendors) - Searchable/filterable vendor list - "Sync Now" button to trigger sync - Ability to create platform vendors from Letzshop cache - Add API endpoints for vendor directory management - Add Pydantic schemas for API responses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1520 lines
51 KiB
Python
1520 lines
51 KiB
Python
# 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,
|
|
LetzshopVendorSyncService,
|
|
OrderNotFoundError,
|
|
VendorNotFoundError,
|
|
)
|
|
from app.tasks.letzshop_tasks import process_historical_import
|
|
from models.database.user import User
|
|
from models.schema.letzshop import (
|
|
FulfillmentOperationResponse,
|
|
LetzshopCachedVendorDetail,
|
|
LetzshopCachedVendorDetailResponse,
|
|
LetzshopCachedVendorItem,
|
|
LetzshopCachedVendorListResponse,
|
|
LetzshopConnectionTestRequest,
|
|
LetzshopConnectionTestResponse,
|
|
LetzshopCreateVendorFromCacheResponse,
|
|
LetzshopCredentialsCreate,
|
|
LetzshopCredentialsResponse,
|
|
LetzshopCredentialsUpdate,
|
|
LetzshopHistoricalImportJobResponse,
|
|
LetzshopHistoricalImportStartResponse,
|
|
LetzshopJobItem,
|
|
LetzshopJobsListResponse,
|
|
LetzshopOrderDetailResponse,
|
|
LetzshopOrderItemResponse,
|
|
LetzshopOrderListResponse,
|
|
LetzshopOrderResponse,
|
|
LetzshopOrderStats,
|
|
LetzshopSuccessResponse,
|
|
LetzshopSyncTriggerRequest,
|
|
LetzshopSyncTriggerResponse,
|
|
LetzshopVendorDirectoryStats,
|
|
LetzshopVendorDirectoryStatsResponse,
|
|
LetzshopVendorDirectorySyncResponse,
|
|
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}")
|
|
|
|
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
|
|
from app.tasks.dispatcher import task_dispatcher
|
|
|
|
celery_task_id = task_dispatcher.dispatch_historical_import(
|
|
background_tasks=background_tasks,
|
|
job_id=job.id,
|
|
vendor_id=vendor_id,
|
|
)
|
|
|
|
# Store Celery task ID if using Celery
|
|
if celery_task_id:
|
|
job.celery_task_id = celery_task_id
|
|
db.commit()
|
|
|
|
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)],
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Vendor Directory (Letzshop Marketplace Vendors)
|
|
# ============================================================================
|
|
|
|
|
|
def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService:
|
|
"""Get vendor sync service instance."""
|
|
return LetzshopVendorSyncService(db)
|
|
|
|
|
|
@router.post("/vendor-directory/sync")
|
|
def trigger_vendor_directory_sync(
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Trigger a sync of the Letzshop vendor directory.
|
|
|
|
Fetches all vendors from Letzshop's public GraphQL API and updates
|
|
the local cache. This is typically run daily via Celery beat, but
|
|
can be triggered manually here.
|
|
"""
|
|
from app.tasks.celery_tasks.letzshop import sync_vendor_directory
|
|
|
|
# Try to dispatch via Celery first
|
|
try:
|
|
task = sync_vendor_directory.delay()
|
|
logger.info(
|
|
f"Admin {current_admin.email} triggered vendor directory sync (task={task.id})"
|
|
)
|
|
return {
|
|
"success": True,
|
|
"message": "Vendor directory sync started",
|
|
"task_id": task.id,
|
|
"mode": "celery",
|
|
}
|
|
except Exception as e:
|
|
# Fall back to background tasks
|
|
logger.warning(f"Celery dispatch failed, using background tasks: {e}")
|
|
|
|
def run_sync():
|
|
from app.core.database import SessionLocal
|
|
|
|
sync_db = SessionLocal()
|
|
try:
|
|
sync_service = LetzshopVendorSyncService(sync_db)
|
|
sync_service.sync_all_vendors()
|
|
finally:
|
|
sync_db.close()
|
|
|
|
background_tasks.add_task(run_sync)
|
|
logger.info(
|
|
f"Admin {current_admin.email} triggered vendor directory sync (background task)"
|
|
)
|
|
return {
|
|
"success": True,
|
|
"message": "Vendor directory sync started",
|
|
"mode": "background_task",
|
|
}
|
|
|
|
|
|
@router.get(
|
|
"/vendor-directory/stats",
|
|
response_model=LetzshopVendorDirectoryStatsResponse,
|
|
)
|
|
def get_vendor_directory_stats(
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
) -> LetzshopVendorDirectoryStatsResponse:
|
|
"""
|
|
Get statistics about the Letzshop vendor directory cache.
|
|
|
|
Returns total, active, claimed, and unclaimed vendor counts.
|
|
"""
|
|
sync_service = get_vendor_sync_service(db)
|
|
stats_data = sync_service.get_sync_stats()
|
|
return LetzshopVendorDirectoryStatsResponse(
|
|
stats=LetzshopVendorDirectoryStats(**stats_data)
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/vendor-directory/vendors",
|
|
response_model=LetzshopCachedVendorListResponse,
|
|
)
|
|
def list_cached_vendors(
|
|
search: str | None = Query(None, description="Search by name"),
|
|
city: str | None = Query(None, description="Filter by city"),
|
|
category: str | None = Query(None, description="Filter by category"),
|
|
only_unclaimed: bool = Query(False, description="Only show unclaimed vendors"),
|
|
page: int = Query(1, ge=1),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
) -> LetzshopCachedVendorListResponse:
|
|
"""
|
|
List cached Letzshop vendors with search and filtering.
|
|
|
|
This returns vendors from the local cache, not directly from Letzshop.
|
|
"""
|
|
sync_service = get_vendor_sync_service(db)
|
|
vendors, total = sync_service.search_cached_vendors(
|
|
search=search,
|
|
city=city,
|
|
category=category,
|
|
only_unclaimed=only_unclaimed,
|
|
page=page,
|
|
limit=limit,
|
|
)
|
|
|
|
return LetzshopCachedVendorListResponse(
|
|
vendors=[
|
|
LetzshopCachedVendorItem(
|
|
id=v.id,
|
|
letzshop_id=v.letzshop_id,
|
|
slug=v.slug,
|
|
name=v.name,
|
|
company_name=v.company_name,
|
|
email=v.email,
|
|
phone=v.phone,
|
|
website=v.website,
|
|
city=v.city,
|
|
categories=v.categories or [],
|
|
is_active=v.is_active,
|
|
is_claimed=v.is_claimed,
|
|
claimed_by_vendor_id=v.claimed_by_vendor_id,
|
|
last_synced_at=v.last_synced_at,
|
|
letzshop_url=v.letzshop_url,
|
|
)
|
|
for v in vendors
|
|
],
|
|
total=total,
|
|
page=page,
|
|
limit=limit,
|
|
has_more=(page * limit) < total,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/vendor-directory/vendors/{slug}",
|
|
response_model=LetzshopCachedVendorDetailResponse,
|
|
)
|
|
def get_cached_vendor_detail(
|
|
slug: str = Path(..., description="Letzshop vendor slug"),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
) -> LetzshopCachedVendorDetailResponse:
|
|
"""
|
|
Get detailed information about a cached Letzshop vendor.
|
|
"""
|
|
sync_service = get_vendor_sync_service(db)
|
|
vendor = sync_service.get_cached_vendor(slug)
|
|
|
|
if not vendor:
|
|
raise ResourceNotFoundException("LetzshopVendor", slug)
|
|
|
|
return LetzshopCachedVendorDetailResponse(
|
|
vendor=LetzshopCachedVendorDetail(
|
|
id=vendor.id,
|
|
letzshop_id=vendor.letzshop_id,
|
|
slug=vendor.slug,
|
|
name=vendor.name,
|
|
company_name=vendor.company_name,
|
|
description_en=vendor.description_en,
|
|
description_fr=vendor.description_fr,
|
|
description_de=vendor.description_de,
|
|
email=vendor.email,
|
|
phone=vendor.phone,
|
|
fax=vendor.fax,
|
|
website=vendor.website,
|
|
street=vendor.street,
|
|
street_number=vendor.street_number,
|
|
city=vendor.city,
|
|
zipcode=vendor.zipcode,
|
|
country_iso=vendor.country_iso,
|
|
latitude=vendor.latitude,
|
|
longitude=vendor.longitude,
|
|
categories=vendor.categories or [],
|
|
background_image_url=vendor.background_image_url,
|
|
social_media_links=vendor.social_media_links or [],
|
|
opening_hours_en=vendor.opening_hours_en,
|
|
opening_hours_fr=vendor.opening_hours_fr,
|
|
opening_hours_de=vendor.opening_hours_de,
|
|
representative_name=vendor.representative_name,
|
|
representative_title=vendor.representative_title,
|
|
is_active=vendor.is_active,
|
|
is_claimed=vendor.is_claimed,
|
|
claimed_by_vendor_id=vendor.claimed_by_vendor_id,
|
|
claimed_at=vendor.claimed_at,
|
|
last_synced_at=vendor.last_synced_at,
|
|
letzshop_url=vendor.letzshop_url,
|
|
)
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/vendor-directory/vendors/{slug}/create-vendor",
|
|
response_model=LetzshopCreateVendorFromCacheResponse,
|
|
)
|
|
def create_vendor_from_letzshop(
|
|
slug: str = Path(..., description="Letzshop vendor slug"),
|
|
company_id: int = Query(..., description="Company ID to create vendor under"),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
) -> LetzshopCreateVendorFromCacheResponse:
|
|
"""
|
|
Create a platform vendor from a cached Letzshop vendor.
|
|
|
|
This creates a new vendor on the platform using information from the
|
|
Letzshop vendor cache. The vendor will be linked to the specified company.
|
|
|
|
Args:
|
|
slug: The Letzshop vendor slug
|
|
company_id: The company ID to create the vendor under
|
|
"""
|
|
sync_service = get_vendor_sync_service(db)
|
|
|
|
try:
|
|
vendor_info = sync_service.create_vendor_from_cache(slug, company_id)
|
|
|
|
logger.info(
|
|
f"Admin {current_admin.email} created vendor {vendor_info['vendor_code']} "
|
|
f"from Letzshop vendor {slug}"
|
|
)
|
|
|
|
return LetzshopCreateVendorFromCacheResponse(
|
|
message=f"Vendor '{vendor_info['name']}' created successfully",
|
|
vendor=vendor_info,
|
|
letzshop_vendor_slug=slug,
|
|
)
|
|
|
|
except ValueError as e:
|
|
raise ValidationException(str(e))
|