diff --git a/app/api/v1/admin/vendor_products.py b/app/api/v1/admin/vendor_products.py index f7c2512f..c8a4abc6 100644 --- a/app/api/v1/admin/vendor_products.py +++ b/app/api/v1/admin/vendor_products.py @@ -206,11 +206,12 @@ def get_vendor_products( @router.get("/stats", response_model=VendorProductStats) def get_vendor_product_stats( + vendor_id: int | None = Query(None, description="Filter stats by vendor ID"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """Get vendor product statistics for admin dashboard.""" - stats = vendor_product_service.get_product_stats(db) + stats = vendor_product_service.get_product_stats(db, vendor_id=vendor_id) return VendorProductStats(**stats) diff --git a/app/api/v1/admin/vendors.py b/app/api/v1/admin/vendors.py index 62dc92a0..1016f412 100644 --- a/app/api/v1/admin/vendors.py +++ b/app/api/v1/admin/vendors.py @@ -363,3 +363,83 @@ def export_vendor_products_letzshop( "Content-Disposition": f'attachment; filename="{filename}"', }, ) + + +class LetzshopExportRequest(BaseModel): + """Request body for Letzshop export to pickup folder.""" + + include_inactive: bool = False + + +@router.post("/{vendor_identifier}/export/letzshop") +def export_vendor_products_letzshop_to_folder( + vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), + request: LetzshopExportRequest = None, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Export vendor products to Letzshop pickup folder (Admin only). + + Generates CSV files for all languages (FR, DE, EN) and places them in a folder + that Letzshop scheduler can fetch from. This is the preferred method for + automated product sync. + + **Behavior:** + - Creates CSV files for each language (fr, de, en) + - Places files in: exports/letzshop/{vendor_code}/ + - Filename format: {vendor_code}_products_{language}.csv + + Returns: + JSON with export status and file paths + """ + import os + from pathlib import Path as FilePath + + from app.services.letzshop_export_service import letzshop_export_service + + vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier) + + include_inactive = request.include_inactive if request else False + + # Create export directory + export_dir = FilePath(f"exports/letzshop/{vendor.vendor_code.lower()}") + export_dir.mkdir(parents=True, exist_ok=True) + + exported_files = [] + languages = ["fr", "de", "en"] + + for lang in languages: + try: + csv_content = letzshop_export_service.export_vendor_products( + db=db, + vendor_id=vendor.id, + language=lang, + include_inactive=include_inactive, + ) + + filename = f"{vendor.vendor_code.lower()}_products_{lang}.csv" + filepath = export_dir / filename + + with open(filepath, "w", encoding="utf-8") as f: + f.write(csv_content) + + exported_files.append({ + "language": lang, + "filename": filename, + "path": str(filepath), + "size_bytes": os.path.getsize(filepath), + }) + except Exception as e: + exported_files.append({ + "language": lang, + "error": str(e), + }) + + return { + "success": True, + "message": f"Exported {len([f for f in exported_files if 'error' not in f])} language(s) to {export_dir}", + "vendor_code": vendor.vendor_code, + "export_directory": str(export_dir), + "files": exported_files, + } diff --git a/app/services/vendor_product_service.py b/app/services/vendor_product_service.py index f8a52f65..cfd0be06 100644 --- a/app/services/vendor_product_service.py +++ b/app/services/vendor_product_service.py @@ -75,12 +75,24 @@ class VendorProductService: return result, total - def get_product_stats(self, db: Session) -> dict: - """Get vendor product statistics for admin dashboard.""" - total = db.query(func.count(Product.id)).scalar() or 0 + def get_product_stats(self, db: Session, vendor_id: int | None = None) -> dict: + """Get vendor product statistics for admin dashboard. + + Args: + db: Database session + vendor_id: Optional vendor ID to filter stats + + Returns: + Dict with product counts (total, active, inactive, etc.) + """ + # Base query filter + base_filter = Product.vendor_id == vendor_id if vendor_id else True + + total = db.query(func.count(Product.id)).filter(base_filter).scalar() or 0 active = ( db.query(func.count(Product.id)) + .filter(base_filter) .filter(Product.is_active == True) # noqa: E712 .scalar() or 0 @@ -89,6 +101,7 @@ class VendorProductService: featured = ( db.query(func.count(Product.id)) + .filter(base_filter) .filter(Product.is_featured == True) # noqa: E712 .scalar() or 0 @@ -97,6 +110,7 @@ class VendorProductService: # Digital/physical counts digital = ( db.query(func.count(Product.id)) + .filter(base_filter) .join(Product.marketplace_product) .filter(Product.marketplace_product.has(is_digital=True)) .scalar() @@ -104,17 +118,19 @@ class VendorProductService: ) physical = total - digital - # Count by vendor - vendor_counts = ( - db.query( - Vendor.name, - func.count(Product.id), + # Count by vendor (only when not filtered by vendor_id) + by_vendor = {} + if not vendor_id: + vendor_counts = ( + db.query( + Vendor.name, + func.count(Product.id), + ) + .join(Vendor, Product.vendor_id == Vendor.id) + .group_by(Vendor.name) + .all() ) - .join(Vendor, Product.vendor_id == Vendor.id) - .group_by(Vendor.name) - .all() - ) - by_vendor = {name or "unknown": count for name, count in vendor_counts} + by_vendor = {name or "unknown": count for name, count in vendor_counts} return { "total": total, diff --git a/app/templates/admin/partials/letzshop-products-tab.html b/app/templates/admin/partials/letzshop-products-tab.html index d0f6fcf0..a108e61b 100644 --- a/app/templates/admin/partials/letzshop-products-tab.html +++ b/app/templates/admin/partials/letzshop-products-tab.html @@ -1,236 +1,382 @@ {# app/templates/admin/partials/letzshop-products-tab.html #} -{# Products tab for admin Letzshop management - Import & Export #} -{% from 'shared/macros/inputs.html' import number_stepper %} +{# Products tab for admin Letzshop management - Product listing with Import/Export #} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/tables.html' import table_wrapper %} -
- -
-
-

- Import Products from Letzshop -

-

- Import products from a Letzshop CSV feed into the marketplace catalog. + +

+
+

Letzshop Products

+

Vendor products synced with Letzshop marketplace

+
+
+ + + + +
+
+ + +
+ +
+
+ +
+
+

Total Products

+

+
+
+ + +
+
+ +
+
+

Active

+

+
+
+ + +
+
+ +
+
+

Inactive

+

+
+
+ + +
+
+ +
+
+

Last Sync

+

+
+
+
+ + +
+
+ +
+
+ + + + +
+
+ + +
+ + + + + +
+
+
+ + +
+ + Loading products... +
+ + +
+ {% call table_wrapper() %} + + + Product + Identifiers + Price + Status + Actions + + + + + + + + + + {% endcall %} + + +
+
+
+ Showing + + to + + of + + products +
+
+ + +
+
+
+
+ + +
+
+
+

Import Products from Letzshop

+ +
+ +

+ Import products from Letzshop CSV feeds. All languages will be imported. +

+ + +
+ +
+ +
+

+ Imports products from all configured CSV URLs (FR, EN, DE)

+
-
+
+

Or import from custom URL:

+
- -
- -
- - - -
-
-
-
- - -
- -
- - {{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }} -

- Products processed per batch (100-5000) -

+
+ +
- - -
- - -
-
-

- Export Products to Letzshop -

-

- Generate a Letzshop-compatible CSV file from this vendor's product catalog. -

- - -
- -

- Select the language for product titles and descriptions -

-
- - - -
-
- - -
- -

- Export products that are currently marked as inactive -

-
- - - - - -
-

CSV Format

-
    -
  • - - Tab-separated values (TSV) -
  • -
  • - - UTF-8 encoding -
  • -
  • - - Google Shopping compatible -
  • -
  • - - 41 fields including price, stock, images -
  • -
-
-
-
diff --git a/app/templates/admin/partials/letzshop-settings-tab.html b/app/templates/admin/partials/letzshop-settings-tab.html index 8ba0fe59..1240a1b8 100644 --- a/app/templates/admin/partials/letzshop-settings-tab.html +++ b/app/templates/admin/partials/letzshop-settings-tab.html @@ -1,5 +1,6 @@ {# app/templates/admin/partials/letzshop-settings-tab.html #} -{# Settings tab for admin Letzshop management - API credentials and CSV URLs #} +{# Settings tab for admin Letzshop management - API credentials, CSV URLs, Import/Export settings #} +{% from 'shared/macros/inputs.html' import number_stepper %}
@@ -236,6 +237,75 @@
+ +
+
+

+ Import / Export Settings +

+

+ Configure settings for product import and export operations. +

+ + +
+

+ + Import Settings +

+
+ + {{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }} +

+ Products processed per batch (100-5000). Higher = faster but more memory. +

+
+
+ + +
+

+ + Export Settings +

+
+ +

+ Export products that are currently marked as inactive +

+
+
+ + +
+

Export Behavior

+
    +
  • + + Exports all languages (FR, DE, EN) automatically +
  • +
  • + + CSV files are placed in a folder for Letzshop pickup +
  • +
  • + + Letzshop scheduler fetches files periodically +
  • +
+
+
+
+
diff --git a/static/admin/js/marketplace-letzshop.js b/static/admin/js/marketplace-letzshop.js index 80c67395..bd494078 100644 --- a/static/admin/js/marketplace-letzshop.js +++ b/static/admin/js/marketplace-letzshop.js @@ -115,6 +115,16 @@ function adminMarketplaceLetzshop() { jobsFilter: { type: '', status: '' }, jobsPagination: { page: 1, per_page: 10, total: 0 }, + // Products Tab + products: [], + totalProducts: 0, + productsPage: 1, + productsLimit: 20, + loadingProducts: false, + productFilters: { search: '', is_active: '' }, + productStats: { total: 0, active: 0, inactive: 0, last_sync: null }, + showImportModal: false, + // Modals showTrackingModal: false, showOrderModal: false, @@ -300,11 +310,12 @@ function adminMarketplaceLetzshop() { // Load Letzshop status and credentials await this.loadLetzshopStatus(); - // Load orders, exceptions, and jobs + // Load orders, exceptions, products, and jobs await Promise.all([ this.loadOrders(), this.loadExceptions(), this.loadExceptionStats(), + this.loadProducts(), this.loadJobs() ]); @@ -397,49 +408,143 @@ function adminMarketplaceLetzshop() { }, // ═══════════════════════════════════════════════════════════════ - // PRODUCTS TAB - IMPORT + // PRODUCTS TAB - LISTING // ═══════════════════════════════════════════════════════════════ /** - * Quick fill import form from vendor CSV URLs + * Load products for selected vendor */ - quickFillImport(language) { - if (!this.selectedVendor) return; + async loadProducts() { + if (!this.selectedVendor) { + this.products = []; + this.totalProducts = 0; + this.productStats = { total: 0, active: 0, inactive: 0, last_sync: null }; + return; + } - const urlMap = { - 'fr': this.selectedVendor.letzshop_csv_url_fr, - 'en': this.selectedVendor.letzshop_csv_url_en, - 'de': this.selectedVendor.letzshop_csv_url_de - }; + this.loadingProducts = true; - const url = urlMap[language]; - if (url) { - this.importForm.csv_url = url; - this.importForm.language = language; - marketplaceLetzshopLog.info('Quick filled import form:', language, url); + try { + const params = new URLSearchParams({ + vendor_id: this.selectedVendor.id.toString(), + skip: ((this.productsPage - 1) * this.productsLimit).toString(), + limit: this.productsLimit.toString() + }); + + if (this.productFilters.search) { + params.append('search', this.productFilters.search); + } + + if (this.productFilters.is_active !== '') { + params.append('is_active', this.productFilters.is_active); + } + + const response = await apiClient.get(`/admin/vendor-products?${params}`); + this.products = response.products || []; + this.totalProducts = response.total || 0; + + // Update stats + if (response.stats) { + this.productStats = response.stats; + } else { + // Calculate from response if not provided + await this.loadProductStats(); + } + } catch (error) { + marketplaceLetzshopLog.error('Failed to load products:', error); + this.products = []; + this.totalProducts = 0; + } finally { + this.loadingProducts = false; } }, /** - * Start product import + * Load product statistics */ - async startImport() { + async loadProductStats() { + if (!this.selectedVendor) return; + + try { + const response = await apiClient.get(`/admin/vendor-products/stats?vendor_id=${this.selectedVendor.id}`); + this.productStats = { + total: response.total || 0, + active: response.active || 0, + inactive: response.inactive || 0, + last_sync: response.last_sync || null + }; + } catch (error) { + marketplaceLetzshopLog.error('Failed to load product stats:', error); + } + }, + + // ═══════════════════════════════════════════════════════════════ + // PRODUCTS TAB - IMPORT + // ═══════════════════════════════════════════════════════════════ + + /** + * Import all languages from configured CSV URLs + */ + async startImportAllLanguages() { + if (!this.selectedVendor) return; + + this.importing = true; + this.error = ''; + this.successMessage = ''; + this.showImportModal = false; + + try { + const languages = []; + if (this.selectedVendor.letzshop_csv_url_fr) languages.push({ url: this.selectedVendor.letzshop_csv_url_fr, lang: 'fr' }); + if (this.selectedVendor.letzshop_csv_url_en) languages.push({ url: this.selectedVendor.letzshop_csv_url_en, lang: 'en' }); + if (this.selectedVendor.letzshop_csv_url_de) languages.push({ url: this.selectedVendor.letzshop_csv_url_de, lang: 'de' }); + + if (languages.length === 0) { + this.error = 'No CSV URLs configured. Please set them in Settings.'; + this.importing = false; + return; + } + + // Start import jobs for all languages + for (const { url, lang } of languages) { + await apiClient.post('/admin/marketplace-import-jobs', { + vendor_id: this.selectedVendor.id, + source_url: url, + marketplace: 'Letzshop', + language: lang, + batch_size: this.importForm.batch_size + }); + } + + this.successMessage = `Import started for ${languages.length} language(s)`; + await this.loadJobs(); + } catch (error) { + marketplaceLetzshopLog.error('Failed to start import:', error); + this.error = error.message || 'Failed to start import'; + } finally { + this.importing = false; + } + }, + + /** + * Import from custom URL + */ + async startImportFromUrl() { if (!this.selectedVendor || !this.importForm.csv_url) return; this.importing = true; this.error = ''; this.successMessage = ''; + this.showImportModal = false; try { - const payload = { + await apiClient.post('/admin/marketplace-import-jobs', { vendor_id: this.selectedVendor.id, source_url: this.importForm.csv_url, marketplace: 'Letzshop', language: this.importForm.language, batch_size: this.importForm.batch_size - }; - - await apiClient.post('/admin/marketplace-import-jobs', payload); + }); this.successMessage = 'Import job started successfully'; this.importForm.csv_url = ''; @@ -452,36 +557,34 @@ function adminMarketplaceLetzshop() { } }, + /** + * Legacy method for backwards compatibility + */ + async startImport() { + return this.startImportFromUrl(); + }, + // ═══════════════════════════════════════════════════════════════ // PRODUCTS TAB - EXPORT // ═══════════════════════════════════════════════════════════════ /** - * Download product export CSV + * Export products for all languages to Letzshop pickup folder */ - async downloadExport() { + async exportAllLanguages() { if (!this.selectedVendor) return; this.exporting = true; this.error = ''; + this.successMessage = ''; try { - const params = new URLSearchParams({ - language: this.exportLanguage, - include_inactive: this.exportIncludeInactive.toString() + const response = await apiClient.post(`/admin/vendors/${this.selectedVendor.id}/export/letzshop`, { + include_inactive: this.exportIncludeInactive }); - const url = `/api/v1/admin/vendors/${this.selectedVendor.id}/export/letzshop?${params}`; - - // Create a link and trigger download - const link = document.createElement('a'); - link.href = url; - link.download = `${this.selectedVendor.vendor_code}_letzshop_export.csv`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - this.successMessage = 'Export started'; + this.successMessage = response.message || 'Export completed. CSV files are ready for Letzshop pickup.'; + marketplaceLetzshopLog.info('Export completed:', response); } catch (error) { marketplaceLetzshopLog.error('Failed to export:', error); this.error = error.message || 'Failed to export products'; @@ -490,6 +593,24 @@ function adminMarketplaceLetzshop() { } }, + /** + * Legacy download export method + */ + async downloadExport() { + return this.exportAllLanguages(); + }, + + /** + * Format price for display + */ + formatPrice(price, currency = 'EUR') { + if (price == null) return '-'; + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: currency + }).format(price); + }, + // ═══════════════════════════════════════════════════════════════ // ORDERS TAB // ═══════════════════════════════════════════════════════════════