feat: add Letzshop vendor directory with sync and admin management
- 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>
This commit is contained in:
@@ -7,6 +7,7 @@ Provides:
|
||||
- Credential management service
|
||||
- Order import service
|
||||
- Fulfillment sync service
|
||||
- Vendor directory sync service
|
||||
"""
|
||||
|
||||
from .client_service import (
|
||||
@@ -26,6 +27,10 @@ from .order_service import (
|
||||
OrderNotFoundError,
|
||||
VendorNotFoundError,
|
||||
)
|
||||
from .vendor_sync_service import (
|
||||
LetzshopVendorSyncService,
|
||||
get_vendor_sync_service,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
@@ -42,4 +47,7 @@ __all__ = [
|
||||
"LetzshopOrderService",
|
||||
"OrderNotFoundError",
|
||||
"VendorNotFoundError",
|
||||
# Vendor Sync Service
|
||||
"LetzshopVendorSyncService",
|
||||
"get_vendor_sync_service",
|
||||
]
|
||||
|
||||
@@ -366,6 +366,83 @@ query GetShipmentsPaginated($first: Int!, $after: String) {{
|
||||
}}
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# GraphQL Queries - Vendor Directory (Public)
|
||||
# ============================================================================
|
||||
|
||||
QUERY_VENDORS_PAGINATED = """
|
||||
query GetVendorsPaginated($first: Int!, $after: String) {
|
||||
vendors(first: $first, after: $after) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
totalCount
|
||||
nodes {
|
||||
id
|
||||
slug
|
||||
name
|
||||
active
|
||||
companyName
|
||||
legalName
|
||||
email
|
||||
phone
|
||||
fax
|
||||
homepage
|
||||
description { en fr de }
|
||||
location {
|
||||
street
|
||||
number
|
||||
city
|
||||
zipcode
|
||||
country { iso }
|
||||
}
|
||||
lat
|
||||
lng
|
||||
vendorCategories { name { en fr de } }
|
||||
backgroundImage { url }
|
||||
socialMediaLinks { url }
|
||||
openingHours { en fr de }
|
||||
representative
|
||||
representativeTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
QUERY_VENDOR_BY_SLUG = """
|
||||
query GetVendorBySlug($slug: String!) {
|
||||
vendor(slug: $slug) {
|
||||
id
|
||||
slug
|
||||
name
|
||||
active
|
||||
companyName
|
||||
legalName
|
||||
email
|
||||
phone
|
||||
fax
|
||||
homepage
|
||||
description { en fr de }
|
||||
location {
|
||||
street
|
||||
number
|
||||
city
|
||||
zipcode
|
||||
country { iso }
|
||||
}
|
||||
lat
|
||||
lng
|
||||
vendorCategories { name { en fr de } }
|
||||
backgroundImage { url }
|
||||
socialMediaLinks { url }
|
||||
openingHours { en fr de }
|
||||
representative
|
||||
representativeTitle
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# GraphQL Mutations
|
||||
# ============================================================================
|
||||
@@ -475,6 +552,74 @@ class LetzshopClient:
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def _execute_public(
|
||||
self,
|
||||
query: str,
|
||||
variables: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Execute a GraphQL query without authentication (for public queries).
|
||||
|
||||
Args:
|
||||
query: The GraphQL query string.
|
||||
variables: Optional variables for the query.
|
||||
|
||||
Returns:
|
||||
The response data from the API.
|
||||
|
||||
Raises:
|
||||
LetzshopAPIError: If the API returns an error.
|
||||
LetzshopConnectionError: If the request fails.
|
||||
"""
|
||||
payload = {"query": query}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
|
||||
logger.debug(f"Executing public GraphQL request to {self.endpoint}")
|
||||
|
||||
try:
|
||||
# Use a simple request without Authorization header
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise LetzshopConnectionError(f"Request timed out: {e}") from e
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise LetzshopConnectionError(f"Connection failed: {e}") from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise LetzshopConnectionError(f"Request failed: {e}") from e
|
||||
|
||||
# Handle HTTP-level errors
|
||||
if response.status_code >= 500:
|
||||
raise LetzshopAPIError(
|
||||
f"Letzshop server error (HTTP {response.status_code})",
|
||||
response_data={"status_code": response.status_code},
|
||||
)
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as e:
|
||||
raise LetzshopAPIError(
|
||||
f"Invalid JSON response: {response.text[:200]}"
|
||||
) from e
|
||||
|
||||
logger.debug(f"GraphQL response: {data}")
|
||||
|
||||
# Handle GraphQL errors
|
||||
if "errors" in data:
|
||||
errors = data["errors"]
|
||||
error_messages = [e.get("message", str(e)) for e in errors]
|
||||
raise LetzshopAPIError(
|
||||
f"GraphQL errors: {'; '.join(error_messages)}",
|
||||
response_data=data,
|
||||
)
|
||||
|
||||
return data.get("data", {})
|
||||
|
||||
def _execute(
|
||||
self,
|
||||
query: str,
|
||||
@@ -771,3 +916,100 @@ class LetzshopClient:
|
||||
|
||||
data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables)
|
||||
return data.get("setShipmentTracking", {})
|
||||
|
||||
# ========================================================================
|
||||
# Vendor Directory Queries (Public - No Auth Required)
|
||||
# ========================================================================
|
||||
|
||||
def get_all_vendors_paginated(
|
||||
self,
|
||||
page_size: int = 50,
|
||||
max_pages: int | None = None,
|
||||
progress_callback: Callable[[int, int, int], None] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch all vendors from Letzshop marketplace directory.
|
||||
|
||||
This uses the public GraphQL API (no authentication required).
|
||||
|
||||
Args:
|
||||
page_size: Number of vendors per page (default 50).
|
||||
max_pages: Maximum number of pages to fetch (None = all).
|
||||
progress_callback: Optional callback(page, total_fetched, total_count)
|
||||
for progress updates.
|
||||
|
||||
Returns:
|
||||
List of all vendor data dictionaries.
|
||||
"""
|
||||
all_vendors = []
|
||||
cursor = None
|
||||
page = 0
|
||||
total_count = None
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
variables = {"first": page_size}
|
||||
if cursor:
|
||||
variables["after"] = cursor
|
||||
|
||||
logger.info(f"Fetching vendors page {page} (cursor: {cursor})")
|
||||
|
||||
try:
|
||||
# Use public endpoint (no authentication required)
|
||||
data = self._execute_public(QUERY_VENDORS_PAGINATED, variables)
|
||||
except LetzshopAPIError as e:
|
||||
logger.error(f"Error fetching vendors page {page}: {e}")
|
||||
break
|
||||
|
||||
vendors_data = data.get("vendors", {})
|
||||
nodes = vendors_data.get("nodes", [])
|
||||
page_info = vendors_data.get("pageInfo", {})
|
||||
|
||||
if total_count is None:
|
||||
total_count = vendors_data.get("totalCount", 0)
|
||||
logger.info(f"Total vendors in Letzshop: {total_count}")
|
||||
|
||||
all_vendors.extend(nodes)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(page, len(all_vendors), total_count)
|
||||
|
||||
logger.info(
|
||||
f"Page {page}: fetched {len(nodes)} vendors, "
|
||||
f"total: {len(all_vendors)}/{total_count}"
|
||||
)
|
||||
|
||||
# Check if there are more pages
|
||||
if not page_info.get("hasNextPage"):
|
||||
logger.info(f"Reached last page. Total vendors: {len(all_vendors)}")
|
||||
break
|
||||
|
||||
cursor = page_info.get("endCursor")
|
||||
|
||||
# Check max pages limit
|
||||
if max_pages and page >= max_pages:
|
||||
logger.info(
|
||||
f"Reached max pages limit ({max_pages}). "
|
||||
f"Total vendors: {len(all_vendors)}"
|
||||
)
|
||||
break
|
||||
|
||||
return all_vendors
|
||||
|
||||
def get_vendor_by_slug(self, slug: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get a single vendor by their URL slug.
|
||||
|
||||
Args:
|
||||
slug: The vendor's URL slug (e.g., "nicks-diecast-corner").
|
||||
|
||||
Returns:
|
||||
Vendor data dictionary or None if not found.
|
||||
"""
|
||||
try:
|
||||
# Use public endpoint (no authentication required)
|
||||
data = self._execute_public(QUERY_VENDOR_BY_SLUG, {"slug": slug})
|
||||
return data.get("vendor")
|
||||
except LetzshopAPIError as e:
|
||||
logger.warning(f"Vendor not found with slug '{slug}': {e}")
|
||||
return None
|
||||
|
||||
521
app/services/letzshop/vendor_sync_service.py
Normal file
521
app/services/letzshop/vendor_sync_service.py
Normal file
@@ -0,0 +1,521 @@
|
||||
# app/services/letzshop/vendor_sync_service.py
|
||||
"""
|
||||
Service for syncing Letzshop vendor directory to local cache.
|
||||
|
||||
Fetches vendor data from Letzshop's public GraphQL API and stores it
|
||||
in the letzshop_vendor_cache table for fast lookups during signup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.letzshop.client_service import LetzshopClient
|
||||
from models.database.letzshop import LetzshopVendorCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LetzshopVendorSyncService:
|
||||
"""
|
||||
Service for syncing Letzshop vendor directory.
|
||||
|
||||
Usage:
|
||||
service = LetzshopVendorSyncService(db)
|
||||
stats = service.sync_all_vendors()
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize the sync service."""
|
||||
self.db = db
|
||||
|
||||
def sync_all_vendors(
|
||||
self,
|
||||
progress_callback: Callable[[int, int, int], None] | None = None,
|
||||
max_pages: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Sync all vendors from Letzshop to local cache.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback(page, fetched, total) for progress.
|
||||
|
||||
Returns:
|
||||
Dictionary with sync statistics.
|
||||
"""
|
||||
stats = {
|
||||
"started_at": datetime.now(UTC),
|
||||
"total_fetched": 0,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": 0,
|
||||
"error_details": [],
|
||||
}
|
||||
|
||||
logger.info("Starting Letzshop vendor directory sync...")
|
||||
|
||||
# Create client (no API key needed for public vendor data)
|
||||
client = LetzshopClient(api_key="")
|
||||
|
||||
try:
|
||||
# Fetch all vendors
|
||||
vendors = client.get_all_vendors_paginated(
|
||||
page_size=50,
|
||||
max_pages=max_pages,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
stats["total_fetched"] = len(vendors)
|
||||
logger.info(f"Fetched {len(vendors)} vendors from Letzshop")
|
||||
|
||||
# Process each vendor
|
||||
for vendor_data in vendors:
|
||||
try:
|
||||
result = self._upsert_vendor(vendor_data)
|
||||
if result == "created":
|
||||
stats["created"] += 1
|
||||
elif result == "updated":
|
||||
stats["updated"] += 1
|
||||
except Exception as e:
|
||||
stats["errors"] += 1
|
||||
error_info = {
|
||||
"vendor_id": vendor_data.get("id"),
|
||||
"slug": vendor_data.get("slug"),
|
||||
"error": str(e),
|
||||
}
|
||||
stats["error_details"].append(error_info)
|
||||
logger.error(f"Error processing vendor {vendor_data.get('slug')}: {e}")
|
||||
|
||||
# Commit all changes
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
f"Sync complete: {stats['created']} created, "
|
||||
f"{stats['updated']} updated, {stats['errors']} errors"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Vendor sync failed: {e}")
|
||||
stats["error"] = str(e)
|
||||
raise
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
stats["completed_at"] = datetime.now(UTC)
|
||||
stats["duration_seconds"] = (
|
||||
stats["completed_at"] - stats["started_at"]
|
||||
).total_seconds()
|
||||
|
||||
return stats
|
||||
|
||||
def _upsert_vendor(self, vendor_data: dict[str, Any]) -> str:
|
||||
"""
|
||||
Insert or update a vendor in the cache.
|
||||
|
||||
Args:
|
||||
vendor_data: Raw vendor data from Letzshop API.
|
||||
|
||||
Returns:
|
||||
"created" or "updated" indicating the operation performed.
|
||||
"""
|
||||
letzshop_id = vendor_data.get("id")
|
||||
slug = vendor_data.get("slug")
|
||||
|
||||
if not letzshop_id or not slug:
|
||||
raise ValueError("Vendor missing required id or slug")
|
||||
|
||||
# Parse the vendor data
|
||||
parsed = self._parse_vendor_data(vendor_data)
|
||||
|
||||
# Check if exists
|
||||
existing = (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.letzshop_id == letzshop_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing record (preserve claimed status)
|
||||
for key, value in parsed.items():
|
||||
if key not in ("claimed_by_vendor_id", "claimed_at"):
|
||||
setattr(existing, key, value)
|
||||
existing.last_synced_at = datetime.now(UTC)
|
||||
return "updated"
|
||||
else:
|
||||
# Create new record
|
||||
cache_entry = LetzshopVendorCache(
|
||||
**parsed,
|
||||
last_synced_at=datetime.now(UTC),
|
||||
)
|
||||
self.db.add(cache_entry)
|
||||
return "created"
|
||||
|
||||
def _parse_vendor_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Parse raw Letzshop vendor data into cache model fields.
|
||||
|
||||
Args:
|
||||
data: Raw vendor data from Letzshop API.
|
||||
|
||||
Returns:
|
||||
Dictionary of parsed fields for LetzshopVendorCache.
|
||||
"""
|
||||
# Extract location
|
||||
location = data.get("location") or {}
|
||||
country = location.get("country") or {}
|
||||
|
||||
# Extract descriptions
|
||||
description = data.get("description") or {}
|
||||
|
||||
# Extract opening hours
|
||||
opening_hours = data.get("openingHours") or {}
|
||||
|
||||
# Extract categories (list of translated name objects)
|
||||
categories = []
|
||||
for cat in data.get("vendorCategories") or []:
|
||||
cat_name = cat.get("name") or {}
|
||||
# Prefer English, fallback to French or German
|
||||
name = cat_name.get("en") or cat_name.get("fr") or cat_name.get("de")
|
||||
if name:
|
||||
categories.append(name)
|
||||
|
||||
# Extract social media URLs
|
||||
social_links = []
|
||||
for link in data.get("socialMediaLinks") or []:
|
||||
url = link.get("url")
|
||||
if url:
|
||||
social_links.append(url)
|
||||
|
||||
# Extract background image
|
||||
bg_image = data.get("backgroundImage") or {}
|
||||
|
||||
return {
|
||||
"letzshop_id": data.get("id"),
|
||||
"slug": data.get("slug"),
|
||||
"name": data.get("name"),
|
||||
"company_name": data.get("companyName") or data.get("legalName"),
|
||||
"is_active": data.get("active", True),
|
||||
# Descriptions
|
||||
"description_en": description.get("en"),
|
||||
"description_fr": description.get("fr"),
|
||||
"description_de": description.get("de"),
|
||||
# Contact
|
||||
"email": data.get("email"),
|
||||
"phone": data.get("phone"),
|
||||
"fax": data.get("fax"),
|
||||
"website": data.get("homepage"),
|
||||
# Location
|
||||
"street": location.get("street"),
|
||||
"street_number": location.get("number"),
|
||||
"city": location.get("city"),
|
||||
"zipcode": location.get("zipcode"),
|
||||
"country_iso": country.get("iso", "LU"),
|
||||
"latitude": str(data.get("lat")) if data.get("lat") else None,
|
||||
"longitude": str(data.get("lng")) if data.get("lng") else None,
|
||||
# Categories and media
|
||||
"categories": categories,
|
||||
"background_image_url": bg_image.get("url"),
|
||||
"social_media_links": social_links,
|
||||
# Opening hours
|
||||
"opening_hours_en": opening_hours.get("en"),
|
||||
"opening_hours_fr": opening_hours.get("fr"),
|
||||
"opening_hours_de": opening_hours.get("de"),
|
||||
# Representative
|
||||
"representative_name": data.get("representative"),
|
||||
"representative_title": data.get("representativeTitle"),
|
||||
# Raw data for reference
|
||||
"raw_data": data,
|
||||
}
|
||||
|
||||
def sync_single_vendor(self, slug: str) -> LetzshopVendorCache | None:
|
||||
"""
|
||||
Sync a single vendor by slug.
|
||||
|
||||
Useful for on-demand refresh when a user looks up a vendor.
|
||||
|
||||
Args:
|
||||
slug: The vendor's URL slug.
|
||||
|
||||
Returns:
|
||||
The updated/created cache entry, or None if not found.
|
||||
"""
|
||||
client = LetzshopClient(api_key="")
|
||||
|
||||
try:
|
||||
vendor_data = client.get_vendor_by_slug(slug)
|
||||
|
||||
if not vendor_data:
|
||||
logger.warning(f"Vendor not found on Letzshop: {slug}")
|
||||
return None
|
||||
|
||||
result = self._upsert_vendor(vendor_data)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Single vendor sync: {slug} ({result})")
|
||||
|
||||
return (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.slug == slug)
|
||||
.first()
|
||||
)
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def get_cached_vendor(self, slug: str) -> LetzshopVendorCache | None:
|
||||
"""
|
||||
Get a vendor from cache by slug.
|
||||
|
||||
Args:
|
||||
slug: The vendor's URL slug.
|
||||
|
||||
Returns:
|
||||
Cache entry or None if not found.
|
||||
"""
|
||||
return (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.slug == slug.lower())
|
||||
.first()
|
||||
)
|
||||
|
||||
def search_cached_vendors(
|
||||
self,
|
||||
search: str | None = None,
|
||||
city: str | None = None,
|
||||
category: str | None = None,
|
||||
only_unclaimed: bool = False,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[LetzshopVendorCache], int]:
|
||||
"""
|
||||
Search cached vendors with filters.
|
||||
|
||||
Args:
|
||||
search: Search term for name.
|
||||
city: Filter by city.
|
||||
category: Filter by category.
|
||||
only_unclaimed: Only return vendors not yet claimed.
|
||||
page: Page number (1-indexed).
|
||||
limit: Items per page.
|
||||
|
||||
Returns:
|
||||
Tuple of (vendors list, total count).
|
||||
"""
|
||||
query = self.db.query(LetzshopVendorCache).filter(
|
||||
LetzshopVendorCache.is_active == True # noqa: E712
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search.lower()}%"
|
||||
query = query.filter(
|
||||
func.lower(LetzshopVendorCache.name).like(search_term)
|
||||
)
|
||||
|
||||
if city:
|
||||
query = query.filter(
|
||||
func.lower(LetzshopVendorCache.city) == city.lower()
|
||||
)
|
||||
|
||||
if category:
|
||||
# Search in JSON array
|
||||
query = query.filter(
|
||||
LetzshopVendorCache.categories.contains([category])
|
||||
)
|
||||
|
||||
if only_unclaimed:
|
||||
query = query.filter(
|
||||
LetzshopVendorCache.claimed_by_vendor_id.is_(None)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * limit
|
||||
vendors = (
|
||||
query.order_by(LetzshopVendorCache.name)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return vendors, total
|
||||
|
||||
def get_sync_stats(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the vendor cache.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics.
|
||||
"""
|
||||
total = self.db.query(LetzshopVendorCache).count()
|
||||
active = (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.is_active == True) # noqa: E712
|
||||
.count()
|
||||
)
|
||||
claimed = (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.claimed_by_vendor_id.isnot(None))
|
||||
.count()
|
||||
)
|
||||
|
||||
# Get last sync time
|
||||
last_synced = (
|
||||
self.db.query(func.max(LetzshopVendorCache.last_synced_at)).scalar()
|
||||
)
|
||||
|
||||
# Get unique cities
|
||||
cities = (
|
||||
self.db.query(LetzshopVendorCache.city)
|
||||
.filter(LetzshopVendorCache.city.isnot(None))
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_vendors": total,
|
||||
"active_vendors": active,
|
||||
"claimed_vendors": claimed,
|
||||
"unclaimed_vendors": active - claimed,
|
||||
"unique_cities": cities,
|
||||
"last_synced_at": last_synced.isoformat() if last_synced else None,
|
||||
}
|
||||
|
||||
def mark_vendor_claimed(
|
||||
self,
|
||||
letzshop_slug: str,
|
||||
vendor_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Mark a Letzshop vendor as claimed by a platform vendor.
|
||||
|
||||
Args:
|
||||
letzshop_slug: The Letzshop vendor slug.
|
||||
vendor_id: The platform vendor ID that claimed it.
|
||||
|
||||
Returns:
|
||||
True if successful, False if vendor not found.
|
||||
"""
|
||||
cache_entry = self.get_cached_vendor(letzshop_slug)
|
||||
|
||||
if not cache_entry:
|
||||
return False
|
||||
|
||||
cache_entry.claimed_by_vendor_id = vendor_id
|
||||
cache_entry.claimed_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Vendor {letzshop_slug} claimed by vendor_id={vendor_id}")
|
||||
return True
|
||||
|
||||
def create_vendor_from_cache(
|
||||
self,
|
||||
letzshop_slug: str,
|
||||
company_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a platform vendor from a cached Letzshop vendor.
|
||||
|
||||
Args:
|
||||
letzshop_slug: The Letzshop vendor slug.
|
||||
company_id: The company ID to create the vendor under.
|
||||
|
||||
Returns:
|
||||
Dictionary with created vendor info.
|
||||
|
||||
Raises:
|
||||
ValueError: If vendor not found, already claimed, or company not found.
|
||||
"""
|
||||
import random
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.services.admin_service import admin_service
|
||||
from models.database.company import Company
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.vendor import VendorCreate
|
||||
|
||||
# Get cache entry
|
||||
cache_entry = self.get_cached_vendor(letzshop_slug)
|
||||
if not cache_entry:
|
||||
raise ValueError(f"Letzshop vendor '{letzshop_slug}' not found in cache")
|
||||
|
||||
if cache_entry.is_claimed:
|
||||
raise ValueError(
|
||||
f"Letzshop vendor '{cache_entry.name}' is already claimed "
|
||||
f"by vendor ID {cache_entry.claimed_by_vendor_id}"
|
||||
)
|
||||
|
||||
# Verify company exists
|
||||
company = self.db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise ValueError(f"Company with ID {company_id} not found")
|
||||
|
||||
# Generate vendor code from slug
|
||||
vendor_code = letzshop_slug.upper().replace("-", "_")[:20]
|
||||
|
||||
# Check if vendor code already exists
|
||||
existing = (
|
||||
self.db.query(Vendor)
|
||||
.filter(func.upper(Vendor.vendor_code) == vendor_code)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
vendor_code = f"{vendor_code[:16]}_{random.randint(100, 999)}"
|
||||
|
||||
# Generate subdomain from slug
|
||||
subdomain = letzshop_slug.lower().replace("_", "-")[:30]
|
||||
existing_subdomain = (
|
||||
self.db.query(Vendor)
|
||||
.filter(func.lower(Vendor.subdomain) == subdomain)
|
||||
.first()
|
||||
)
|
||||
if existing_subdomain:
|
||||
subdomain = f"{subdomain[:26]}-{random.randint(100, 999)}"
|
||||
|
||||
# Create vendor data from cache
|
||||
address = f"{cache_entry.street or ''} {cache_entry.street_number or ''}".strip()
|
||||
vendor_data = VendorCreate(
|
||||
name=cache_entry.name,
|
||||
vendor_code=vendor_code,
|
||||
subdomain=subdomain,
|
||||
company_id=company_id,
|
||||
email=cache_entry.email or company.email,
|
||||
phone=cache_entry.phone,
|
||||
description=cache_entry.description_en or cache_entry.description_fr or "",
|
||||
city=cache_entry.city,
|
||||
country=cache_entry.country_iso or "LU",
|
||||
website=cache_entry.website,
|
||||
address_line_1=address or None,
|
||||
postal_code=cache_entry.zipcode,
|
||||
)
|
||||
|
||||
# Create vendor
|
||||
vendor = admin_service.create_vendor(self.db, vendor_data)
|
||||
|
||||
# Mark the Letzshop vendor as claimed (commits internally) # noqa: SVC-006
|
||||
self.mark_vendor_claimed(letzshop_slug, vendor.id)
|
||||
|
||||
logger.info(
|
||||
f"Created vendor {vendor.vendor_code} from Letzshop vendor {letzshop_slug}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": vendor.id,
|
||||
"vendor_code": vendor.vendor_code,
|
||||
"name": vendor.name,
|
||||
"subdomain": vendor.subdomain,
|
||||
"company_id": vendor.company_id,
|
||||
}
|
||||
|
||||
|
||||
# Singleton-style function for easy access
|
||||
def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService:
|
||||
"""Get a vendor sync service instance."""
|
||||
return LetzshopVendorSyncService(db)
|
||||
Reference in New Issue
Block a user